@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types ā generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
4,658 lines ⢠213 kB
JavaScript
import fs from "node:fs";
import path from "node:path";
import chalk from "chalk";
import ora from "ora";
import { STEP_LIMITS } from "../../core/constants.js";
import { ModelResolver } from "../../models/modelResolver.js";
import { providerChoicesFor } from "../../factories/mediaHandlerCatalog.js";
import { globalSession } from "../../session/globalSessionState.js";
// Use TokenUsage from standard types - no local interface needed
import { ContextFactory, } from "../../types/index.js";
import { checkRedisAvailability } from "../../utils/conversationMemory.js";
import { normalizeEvaluationData } from "../../utils/evaluationUtils.js";
import { logger } from "../../utils/logger.js";
import { sniffImageMimeType } from "../../utils/imageDetection.js";
import { createThinkingConfigFromRecord } from "../../utils/thinkingConfig.js";
import { buildToolRoutingConfigFromCli } from "../utils/toolRoutingFlags.js";
import { buildClassifierRouterConfigFromCli } from "../utils/classifierRouterFlags.js";
import { buildSkillsConfigFromCli } from "../utils/skillsFlags.js";
import { ensureStdinRef } from "../utils/stdinRef.js";
import { SkillsManager } from "../../skills/skillsManager.js";
import { configManager } from "../commands/config.js";
import { MCPCommandFactory } from "../commands/mcp.js";
import { ModelsCommandFactory } from "../commands/models.js";
import { handleSetup } from "../commands/setup.js";
import { handleError } from "../errorHandler.js";
import { LoopSession } from "../loop/session.js";
import { initializeCliParser } from "../parser.js";
import { formatFileSize, saveAudioToFile } from "../utils/audioFileUtils.js";
import { playAudio } from "../utils/audioPlayer.js";
import { resolveFilePaths } from "../utils/pathResolver.js";
import { validateCliInputFiles, validateCsvMaxRows, validatePromptsFilePath, } from "../utils/inputValidation.js";
import { animatedWrite } from "../utils/typewriter.js";
import { createStreamAbortHandler } from "../utils/abortHandler.js";
import { formatVideoFileSize, getVideoMetadataSummary, saveVideoToFile, } from "../utils/videoFileUtils.js";
import { OllamaCommandFactory } from "./ollamaCommandFactory.js";
import { SageMakerCommandFactory } from "./sagemakerCommandFactory.js";
import { AgentCommandFactory } from "../commands/agent.js";
import { PROVIDER_DESCRIPTORS } from "../../factories/providerDescriptors.js";
/**
* Every provider name + alias, derived from PROVIDER_DESCRIPTORS, plus
* "auto" and the CLI-only "anthropic-subscription" pseudo-provider (not a
* real AIProviderName ā special-cased at runtime to rewrite
* options.provider to "anthropic").
*/
const DERIVED_PROVIDER_CHOICES = [
"auto",
...PROVIDER_DESCRIPTORS.flatMap((d) => [d.name, ...d.aliases]),
"anthropic-subscription",
];
/** Space-separated form for the bash-completion script, kept in sync with DERIVED_PROVIDER_CHOICES by construction. */
export const BASH_COMPLETION_PROVIDERS = DERIVED_PROVIDER_CHOICES.join(" ");
/** EX_IOERR (sysexits.h): the result could not be fully written to stdout. */
const STDOUT_DRAIN_FAILED_EXIT_CODE = 74;
const DEFAULT_STDOUT_DRAIN_TIMEOUT_MS = 30_000;
const STDOUT_DRAIN_WARNING = "neurolink: stdout drain timed out; output may be incomplete\n";
let stdoutDrainTimeoutMs;
const resolveStdoutDrainTimeoutMs = () => {
if (stdoutDrainTimeoutMs === undefined) {
const override = Number(process.env.NEUROLINK_STDOUT_DRAIN_TIMEOUT_MS);
stdoutDrainTimeoutMs =
Number.isFinite(override) && override > 0
? override
: DEFAULT_STDOUT_DRAIN_TIMEOUT_MS;
}
return stdoutDrainTimeoutMs;
};
/**
* CLI Command Factory for generate commands
*/
export class CLICommandFactory {
/**
* Normalize loop session variables before merging them into provider options.
*
* The CLI loop schema models some fields (e.g. `stopSequences`,
* `enabledToolNames`) as a single comma-separated string for ergonomic
* input, but providers expect `string[]`. Without conversion,
* `set stopSequences a,b` would be sent as one stop token "a,b" instead
* of two ("a", "b"); `set enabledToolNames read,write` would be cast to
* a `string[]` containing the single literal "read,write" and silently
* filter out every tool. This helper splits and trims those fields so
* the spread into `enhancedOptions` produces the correct shape across
* generate / batch / stream paths.
*/
static normalizeLoopSessionVariables(vars) {
const normalized = { ...vars };
if (typeof normalized.stopSequences === "string") {
normalized.stopSequences = normalized.stopSequences
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
if (typeof normalized.enabledToolNames === "string") {
normalized.enabledToolNames = normalized.enabledToolNames
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
return normalized;
}
// Common options available on all commands
static commonOptions = {
// Core generation options
provider: {
choices: DERIVED_PROVIDER_CHOICES,
default: "auto",
description: "AI provider to use (auto-selects best available). Use 'anthropic-subscription' for Claude subscription plans.",
alias: "p",
},
// Anthropic subscription options
authMethod: {
type: "string",
choices: ["api-key", "oauth"],
default: "api-key",
description: "Authentication method for Anthropic: 'api-key' (default) or 'oauth' (for subscription plans)",
},
subscriptionTier: {
type: "string",
choices: ["free", "pro", "max", "max_5", "max_20", "api"],
description: "Anthropic subscription tier: free (limited), pro ($20/mo), max (highest limits), max_5/max_20 (extended), api (pay-per-use)",
},
enableBeta: {
type: "boolean",
default: false,
description: "Enable Anthropic beta features (experimental capabilities, computer use, etc.)",
alias: "beta",
},
image: {
type: "string",
description: "Add image file for multimodal analysis (can be used multiple times)",
alias: "i",
},
csv: {
type: "string",
description: "Add CSV file for data analysis (can be used multiple times)",
alias: "c",
},
pdf: {
type: "string",
description: "Add PDF file for analysis (can be used multiple times)",
},
"pdf-password": {
type: "string",
description: "Password for an encrypted PDF (used on the image-conversion path). " +
"Visible in shell history/process listings ā prefer the " +
"NEUROLINK_PDF_PASSWORD env var instead.",
},
video: {
type: "string",
description: "Add video file for analysis (can be used multiple times) (MP4, WebM, MOV, AVI, MKV)",
},
// No yargs `default:` on these two. They used to declare 8 / 85 while the
// processor actually picks a duration-based frame count (up to 100) and
// encodes at quality 80 ā harmless only because the values were never read
// (#478). Now that they reach the encoder, a default here would silently
// re-cap every existing CLI video at 8 frames. Unset means "let the
// processor choose", which is what callers have always effectively had.
"video-frames": {
type: "number",
description: "Number of frames to extract (default: chosen from video duration, max 100)",
},
"video-quality": {
type: "number",
description: "Frame quality 1-100 (default: 80)",
},
"video-format": {
type: "string",
choices: ["jpeg", "png"],
default: "jpeg",
description: "Frame format (default: jpeg)",
},
"transcribe-audio": {
type: "boolean",
default: false,
description: "Transcribe the video's spoken audio (needs ffmpeg and OPENAI_API_KEY). " +
"Unnecessary for Gemini, which hears the clip directly",
},
file: {
type: "string",
description: "Add file with auto-detection (CSV, image, etc. - can be used multiple times)",
},
csvMaxRows: {
type: "number",
default: 1000,
description: "Maximum number of CSV rows to process (positive integer, range 1-100000, default 1000)",
},
csvFormat: {
type: "string",
choices: ["raw", "markdown", "json"],
default: "raw",
description: "CSV output format:\n" +
" ⢠raw: Plain CSV text (fastest, minimal tokens, best for large files)\n" +
" ⢠markdown: Formatted table (readable, best for small files <100 rows)\n" +
" ⢠json: Structured JSON array (best for programmatic use, higher tokens)",
},
"csv-encoding": {
type: "string",
description: "Character encoding for CSV files (e.g. utf-8, utf-16le, windows-1252). Auto-detected when omitted (#362).",
},
"csv-sanitize-names": {
type: "boolean",
default: false,
description: "Rewrite CSV column headers into valid identifiers (e.g. 'Price ($)' ā 'price') (#378).",
},
"csv-name-case": {
type: "string",
choices: ["snake_case", "camelCase"],
default: "snake_case",
description: "Case style for sanitized CSV column names (used with --csv-sanitize-names) (#378).",
},
"csv-parse-timeout-ms": {
type: "number",
description: "Wall-clock cap (ms) for CSV parsing; returns partial rows on timeout (#379).",
},
"csv-skip-empty-lines": {
type: "boolean",
default: true,
description: "Skip blank/whitespace-only CSV data rows in content and rowCount (default true). Use --no-csv-skip-empty-lines to preserve them (#373).",
},
model: {
type: "string",
description: "Specific model to use (e.g. gemini-2.5-pro, gemini-2.5-flash)",
alias: "m",
},
temperature: {
type: "number",
default: 0.7,
description: "Creativity level (0.0 = focused, 1.0 = creative)",
alias: "t",
},
maxTokens: {
type: "number",
default: 1000,
description: "Maximum tokens to generate",
alias: "max",
},
system: {
type: "string",
description: "System prompt to guide AI behavior",
alias: "s",
},
toolRoot: {
type: "string",
array: true,
description: "Directory the built-in file tools may read and write (repeatable). Defaults to the working directory. Constrains bash's working directory, not its commands.",
},
// Output control options
format: {
choices: ["text", "json", "table"],
default: "text",
alias: ["f", "output-format"],
description: "Output format",
},
output: {
type: "string",
description: "Save output to file",
alias: "o",
},
imageOutput: {
type: "string",
description: "Custom path for generated image (default: generated-images/image-<timestamp>.<ext>, where the extension follows the format the provider actually returned)",
alias: "image-output",
},
// Behavior control options
timeout: {
type: "number",
default: 120,
description: "Maximum execution time in seconds",
},
delay: {
type: "number",
description: "Delay between operations (ms)",
},
// Tools & features options
disableTools: {
type: "boolean",
default: false,
description: "Disable MCP tool integration (tools enabled by default)",
},
enableAnalytics: {
type: "boolean",
default: false,
description: "Enable usage analytics collection",
},
enableEvaluation: {
type: "boolean",
default: false,
description: "Enable AI response quality evaluation",
},
domain: {
type: "string",
choices: [
"healthcare",
"finance",
"analytics",
"ecommerce",
"education",
"legal",
"technology",
"generic",
"auto",
],
description: "Domain type for specialized processing and optimization",
alias: "d",
},
evaluationDomain: {
type: "string",
description: "Domain expertise for evaluation (e.g., 'AI coding assistant', 'Customer service expert')",
},
toolUsageContext: {
type: "string",
description: "Tool usage context for evaluation (e.g., 'Used sales-data MCP tools')",
},
domainAware: {
type: "boolean",
default: false,
description: "Use domain-aware evaluation",
},
context: {
type: "string",
description: "JSON context object for custom data",
},
// Debug & output options
debug: {
type: "boolean",
alias: ["v", "verbose"],
default: false,
description: "Enable debug mode with verbose output",
},
quiet: {
type: "boolean",
alias: "q",
default: true,
description: "Suppress non-essential output",
},
noColor: {
type: "boolean",
default: false,
description: "Disable colored output (useful for CI/scripts)",
},
configFile: {
type: "string",
description: "Path to custom configuration file",
},
dryRun: {
type: "boolean",
default: false,
description: "Test command without making actual API calls (for testing)",
},
// TTS (Text-to-Speech) options
tts: {
type: "boolean",
default: false,
description: "Enable text-to-speech output",
},
ttsVoice: {
type: "string",
description: "TTS voice to use (e.g., 'en-US-Neural2-C')",
},
ttsProvider: {
type: "string",
choices: providerChoicesFor("tts"),
description: "TTS provider (overrides --provider for speech synthesis)",
},
ttsFormat: {
type: "string",
choices: [
"mp3",
"wav",
"ogg",
"opus",
"m4a",
"flac",
"webm",
"mp4",
"mpeg",
"mpga",
],
default: "mp3",
description: "Audio output format",
},
ttsSpeed: {
type: "number",
default: 1.0,
description: "Speaking rate (0.25-4.0, default: 1.0)",
},
ttsQuality: {
type: "string",
choices: ["standard", "hd"],
default: "standard",
description: "Audio quality level",
},
ttsOutput: {
type: "string",
description: "Save TTS audio to file (supports absolute and relative paths)",
},
ttsPlay: {
type: "boolean",
default: false,
description: "Auto-play generated audio",
},
// STT (Speech-to-Text) options
stt: {
type: "boolean",
default: false,
description: "Enable speech-to-text transcription of input audio",
},
sttProvider: {
type: "string",
choices: providerChoicesFor("stt"),
description: "STT provider to use",
},
sttLanguage: {
type: "string",
description: "Audio language code for STT (e.g., en-US)",
},
inputAudio: {
type: "string",
description: "Path to audio file for STT transcription",
},
// Video Generation options (Veo 3.1, Kling, Runway, Replicate)
outputMode: {
type: "string",
choices: ["text", "video", "ppt", "avatar", "music"],
default: "text",
description: "Output mode: 'text' (default), 'video' (Veo/Kling/Runway/Replicate), 'ppt' (presentation), 'avatar' (D-ID/HeyGen/MuseTalk talking-head), 'music' (Beatoven/ElevenLabs/Lyria/MusicGen)",
},
videoProvider: {
type: "string",
choices: providerChoicesFor("video"),
description: "Video provider override (e.g., 'vertex' (default), 'kling', 'runway', 'replicate')",
},
videoOutput: {
type: "string",
alias: "vo",
description: "Path to save generated video file (e.g., ./output.mp4)",
},
videoResolution: {
type: "string",
choices: ["720p", "1080p"],
description: "Video output resolution (720p or 1080p; provider default applied if omitted)",
},
videoLength: {
type: "number",
choices: [4, 6, 8],
description: "Video duration in seconds (4, 6, or 8; provider default applied if omitted)",
},
videoAspectRatio: {
type: "string",
choices: ["9:16", "16:9"],
description: "Video aspect ratio (9:16 for portrait, 16:9 for landscape; provider default applied if omitted)",
},
videoAudio: {
type: "boolean",
description: "Enable/disable audio generation in video (provider default applied if omitted)",
},
// Avatar Generation options (D-ID, HeyGen, MuseTalk via Replicate)
avatarProvider: {
type: "string",
choices: providerChoicesFor("avatar"),
description: "Avatar provider (e.g., 'd-id' (default), 'heygen', 'replicate', 'musetalk')",
},
avatarImage: {
type: "string",
description: "Path to source portrait image (or HeyGen avatar id when --avatarProvider heygen)",
},
avatarAudio: {
type: "string",
description: "Path to narration audio (alternative to --avatarText)",
},
avatarText: {
type: "string",
description: "Text the avatar should speak (the provider runs TTS internally)",
},
avatarVoice: {
type: "string",
description: "Voice id for TTS-driven avatars (provider-specific catalog id)",
},
avatarQuality: {
type: "string",
choices: ["standard", "hd"],
description: "Avatar output quality preset (provider default applied if omitted)",
},
avatarFormat: {
type: "string",
choices: ["mp4", "webm", "mov"],
description: "Avatar video output format (provider default applied if omitted)",
},
avatarOutput: {
type: "string",
description: "Path to save generated avatar video (e.g., ./avatar.mp4)",
},
// Music Generation options (Beatoven, ElevenLabs, Lyria, MusicGen via Replicate)
musicProvider: {
type: "string",
choices: providerChoicesFor("music"),
description: "Music provider (e.g., 'beatoven' (default), 'elevenlabs-music', 'lyria', 'replicate', 'musicgen')",
},
musicDuration: {
type: "number",
description: "Music duration in seconds (provider-clamped)",
},
musicFormat: {
type: "string",
choices: ["mp3", "wav", "flac", "ogg"],
description: "Music output format",
},
musicGenre: {
type: "string",
description: "Music genre hint (e.g., 'ambient', 'cinematic', 'electronic')",
},
musicMood: {
type: "string",
description: "Music mood hint (e.g., 'uplifting', 'tense', 'melancholic')",
},
musicTempo: {
type: "number",
description: "Music tempo in BPM",
},
musicOutput: {
type: "string",
description: "Path to save generated music (e.g., ./track.mp3)",
},
// PPT Generation options
pptPages: {
type: "number",
alias: "pages",
description: "Number of slides to generate (5-50, default: 10 when PPT mode is enabled)",
},
pptTheme: {
type: "string",
choices: ["modern", "corporate", "creative", "minimal", "dark"],
description: "Presentation theme/style (default: AI selects based on topic)",
},
pptAudience: {
type: "string",
choices: ["business", "students", "technical", "general"],
description: "Target audience (default: AI selects based on topic)",
},
pptTone: {
type: "string",
choices: ["professional", "casual", "educational", "persuasive"],
description: "Presentation tone (default: AI selects based on topic)",
},
pptOutput: {
type: "string",
alias: "po",
description: "Path to save generated PPTX file (e.g., ./output.pptx)",
},
pptAspectRatio: {
type: "string",
choices: ["16:9", "4:3"],
description: "Slide aspect ratio (default: 16:9 when PPT mode is enabled)",
},
pptNoImages: {
type: "boolean",
default: false,
description: "Disable AI image generation for slides",
},
thinking: {
alias: "think",
type: "boolean",
description: "Enable extended thinking/reasoning capability",
default: false,
},
thinkingBudget: {
type: "number",
description: "Token budget for extended thinking - Anthropic Claude and Gemini 2.5+ models (5000-100000)",
default: 10000,
},
thinkingLevel: {
type: "string",
description: "Thinking level for extended reasoning (Anthropic Claude, Gemini 2.5+, Gemini 3): minimal, low, medium, high",
choices: ["minimal", "low", "medium", "high"],
},
// Tool-routing options
toolRouting: {
type: "boolean",
description: "Enable pre-call per-turn tool routing (narrows MCP tools by relevance).",
},
toolRoutingTimeout: {
type: "number",
description: "Router LLM hard timeout in milliseconds.",
alias: "tool-routing-timeout",
},
toolRoutingRouterProvider: {
type: "string",
description: "Override the provider used for the router LLM call.",
alias: "tool-routing-router-provider",
},
toolRoutingRouterModel: {
type: "string",
description: "Override the model used for the router LLM call.",
alias: "tool-routing-router-model",
},
toolRoutingRouterRegion: {
type: "string",
description: "Override the region used for the router LLM call.",
alias: "tool-routing-router-region",
},
toolRoutingAlwaysInclude: {
type: "array",
description: "Server ids whose tools are always kept and never offered to the router (repeatable).",
alias: "tool-routing-always-include",
string: true,
},
toolRoutingServers: {
type: "string",
description: "Path to a JSON file OR inline JSON array of {id, description} server descriptors for the routable catalog.",
alias: "tool-routing-servers",
},
// Classifier-router options
classifierRouter: {
type: "boolean",
description: "Enable the classifier router: classify each request and pick a model (and tools) from --classifier-pool.",
alias: "classifier-router",
},
classifierStrategy: {
type: "string",
description: "Classifier strategy: 'auto' (default ā 'jev' when a decision provider is configured, such as TYPESAFE_API_KEY, LAYA_API_KEY with LAYA_BASE_URL, XOR_API_KEY with XOR_BASE_URL, PERPLEXITY_API_KEY, or CLOUDFLARE_API_KEY with CLOUDFLARE_ACCOUNT_ID, else 'heuristic'), 'heuristic' (no LLM), 'llm' (a cheap model picks per prompt), or 'jev' (a System One decision model ā TypeSafe Jev, Laya, XOR, Perplexity or Cloudflare Clef ā with calibrated confidence).",
choices: ["auto", "heuristic", "llm", "jev"],
alias: "classifier-strategy",
},
classifierMinUpgradeConfidence: {
type: "number",
description: "Minimum confidence to route UP to a costlier model; below it the heuristic is used. Only meaningful for 'jev'. Default: 0.3.",
alias: "classifier-min-upgrade-confidence",
},
classifierMinDowngradeConfidence: {
type: "number",
description: "Minimum confidence to route DOWN to a cheaper model. Higher than the upgrade bar, because the two mistakes cost differently. Default: 0.6.",
alias: "classifier-min-downgrade-confidence",
},
classifierModelProvider: {
type: "string",
description: "Provider for the LLM classifier model (strategy=llm).",
alias: "classifier-model-provider",
},
classifierModelName: {
type: "string",
description: "Model name for the LLM classifier model (strategy=llm).",
alias: "classifier-model-name",
},
classifierModelRegion: {
type: "string",
description: "Region for the LLM classifier model (strategy=llm).",
alias: "classifier-model-region",
},
classifierPool: {
type: "string",
description: "Path to a JSON file OR inline JSON array of pool members: { provider, model?, region?, description?, tiers?, cost?, quality?, id? }.",
alias: "classifier-pool",
},
classifierTimeout: {
type: "number",
description: "LLM classifier hard timeout in milliseconds.",
alias: "classifier-timeout",
},
region: {
type: "string",
description: "Vertex AI region (e.g., us-central1, europe-west1, asia-northeast1)",
alias: "r",
},
// RAG options
ragFiles: {
type: "array",
description: "File paths to load for RAG (Retrieval-Augmented Generation). AI will search these documents to answer your question.",
alias: "rag-files",
string: true,
},
ragStrategy: {
type: "string",
description: "Chunking strategy for RAG documents (auto-detected from file extension if not specified)",
alias: "rag-strategy",
choices: [
"character",
"recursive",
"sentence",
"token",
"markdown",
"html",
"json",
"latex",
"semantic",
"semantic-markdown",
],
},
ragChunkSize: {
type: "number",
description: "Maximum chunk size in characters for RAG documents",
alias: "rag-chunk-size",
default: 1000,
},
ragChunkOverlap: {
type: "number",
description: "Overlap between adjacent chunks for RAG documents",
alias: "rag-chunk-overlap",
default: 200,
},
ragTopK: {
type: "number",
description: "Number of top results to retrieve for RAG",
alias: "rag-top-k",
default: 5,
},
// Safety ā PII detection
piiRedact: {
type: "boolean",
description: "Enable PII detection and redaction on input before sending to LLM",
alias: "pii-redact",
default: false,
},
piiTypes: {
type: "string",
description: "Comma-separated PII types to detect (email,phone,ssn,creditCard,ipAddress,address,name,dateOfBirth,passport,driversLicense)",
alias: "pii-types",
},
piiAction: {
type: "string",
description: "Action when PII is found: redact, abort, or warn",
alias: "pii-action",
default: "redact",
},
// Safety ā Input validation
inputMaxLength: {
type: "number",
description: "Maximum input text length (characters)",
alias: "input-max-length",
},
trimWhitespace: {
type: "boolean",
description: "Trim whitespace from input text",
alias: "trim-whitespace",
default: false,
},
requireContent: {
type: "boolean",
description: "Abort if input text is empty or whitespace",
alias: "require-content",
default: false,
},
// Safety ā Response validation
outputMaxLength: {
type: "number",
description: "Maximum response length (characters). Truncates if exceeded",
alias: "output-max-length",
},
outputMinLength: {
type: "number",
description: "Minimum response length (characters)",
alias: "output-min-length",
},
// Skills options
skillsDir: {
type: "string",
description: "Directory of skills (SOPs/playbooks) to make available to the AI. " +
"Supports <id>.json, <name>.md, and <name>/SKILL.md layouts. " +
"Also settable via NEUROLINK_SKILLS_DIR.",
alias: "skills-dir",
},
};
// Registered on generate and stream only; the flags mean nothing elsewhere.
static agentOptions = {
agentMode: {
type: "boolean",
description: "Run as an autonomous terminal agent: prepend Neurolink's versioned agent instructions to the system prompt",
},
maxSteps: {
type: "number",
description: `Maximum tool-calling steps before the turn stops (integer ${STEP_LIMITS.min}-${STEP_LIMITS.max}, default ${STEP_LIMITS.default}; some providers cap lower)`,
coerce: (value) => {
if (typeof value !== "number" ||
!Number.isInteger(value) ||
value < STEP_LIMITS.min ||
value > STEP_LIMITS.max) {
throw new Error(`--max-steps must be an integer between ${STEP_LIMITS.min} and ${STEP_LIMITS.max}`);
}
return value;
},
},
};
// Helper method to build options for commands. `excludeOptions` drops
// common flags from registration entirely (not just from validation) ā
// #1191 round-5: batch uses this to omit `commonOptions.file` so `--file`
// isn't a recognized flag at all, since it would collide with the
// `<file>` positional (the prompts list).
static buildOptions(yargs, additionalOptions = {}, excludeOptions = []) {
const baseOptions = excludeOptions.length === 0
? CLICommandFactory.commonOptions
: Object.fromEntries(Object.entries(CLICommandFactory.commonOptions).filter(([key]) => !excludeOptions.includes(key)));
return (yargs
.options({
...baseOptions,
...additionalOptions,
})
// NEW9: implies relationships so users who pass --stt-provider or
// --input-audio without --stt get an actionable error from yargs
// instead of silently skipping STT.
.implies("sttProvider", "stt")
.implies("inputAudio", "stt"));
}
// Helper method to process CLI images with smart auto-detection
static processCliImages(images) {
if (!images) {
return undefined;
}
const imagePaths = Array.isArray(images) ? images : [images];
// Resolve relative paths to absolute paths before returning
// URLs are preserved as-is by resolveFilePaths
// File paths will be converted to base64 by the message builder
return resolveFilePaths(imagePaths);
}
// Helper method to process CLI CSV files
static processCliCSVFiles(csvFiles) {
if (!csvFiles) {
return undefined;
}
const paths = Array.isArray(csvFiles) ? csvFiles : [csvFiles];
// Resolve relative paths to absolute paths before returning
// URLs are preserved as-is by resolveFilePaths
return resolveFilePaths(paths);
}
// Helper method to process CLI PDF files
static processCliPDFFiles(pdfFiles) {
if (!pdfFiles) {
return undefined;
}
const paths = Array.isArray(pdfFiles) ? pdfFiles : [pdfFiles];
// Resolve relative paths to absolute paths before returning
// URLs are preserved as-is by resolveFilePaths
return resolveFilePaths(paths);
}
/**
* Resolve the PDF decryption password, preferring the NEUROLINK_PDF_PASSWORD
* env var over the `--pdf-password` flag. A plaintext CLI flag leaks into
* shell history, `ps`/process listings, and CI logs ā the env var avoids
* that, matching the project's existing "credentials via env vars, not
* flags" stance for the loop session. The flag stays supported (dropping it
* would be a breaking CLI change), but using it prints a one-line stderr
* warning recommending the env var instead.
*/
static resolvePdfPassword(argv) {
const flagValue = argv.pdfPassword;
const envValue = process.env.NEUROLINK_PDF_PASSWORD;
if (flagValue) {
process.stderr.write(chalk.yellow("ā ļø --pdf-password is visible in shell history and process listings. " +
"Prefer the NEUROLINK_PDF_PASSWORD environment variable instead.\n"));
}
return flagValue || envValue;
}
// Helper method to process CLI files with auto-detection
static processCliFiles(files) {
if (!files) {
return undefined;
}
const paths = Array.isArray(files) ? files : [files];
// Resolve relative paths to absolute paths before returning
// URLs are preserved as-is by resolveFilePaths
return resolveFilePaths(paths);
}
// Helper method to process CLI video files
static processCliVideoFiles(videoFiles) {
if (!videoFiles) {
return undefined;
}
const paths = Array.isArray(videoFiles) ? videoFiles : [videoFiles];
// Resolve relative paths to absolute paths before returning
// URLs are preserved as-is by resolveFilePaths
return resolveFilePaths(paths);
}
// Helper method to process common options
static processOptions(argv) {
// Handle noColor option by disabling chalk
if (argv.noColor) {
process.env.FORCE_COLOR = "0";
}
// Process context using ContextFactory for type-safe integration
let processedContext;
let contextConfig;
if (argv.context) {
let rawContext;
if (typeof argv.context === "string") {
try {
rawContext = JSON.parse(argv.context);
}
catch (err) {
const contextStr = argv.context;
const truncatedJson = contextStr.length > 100
? `${contextStr.slice(0, 100)}...`
: contextStr;
handleError(new Error(`Invalid JSON in --context parameter: ${err.message}. Received: ${truncatedJson}`), "Context parsing");
}
}
else {
rawContext = argv.context;
}
const validatedContext = ContextFactory.validateContext(rawContext);
if (validatedContext) {
processedContext = validatedContext;
// Configure context integration based on CLI usage
contextConfig = {
mode: "prompt_prefix", // Add context as prompt prefix for CLI usage
includeInPrompt: true,
includeInAnalytics: true,
includeInEvaluation: true,
maxLength: 500, // Reasonable limit for CLI context
};
}
else if (argv.debug) {
logger.debug("Invalid context provided, skipping context integration");
}
}
return {
provider: argv.provider === "auto"
? undefined
: argv.provider,
model: argv.model,
temperature: argv.temperature,
maxTokens: argv.maxTokens,
// Sampling controls ā surfaced here so all three command paths
// (generate / stream / batch) get them consistently typed instead
// of relying on an ad-hoc cast at each sdk call site.
topP: argv.topP,
topK: argv.topK,
stopSequences: argv.stopSequences,
enabledToolNames: argv.enabledToolNames,
systemPrompt: argv.system,
agentMode: argv.agentMode,
maxSteps: argv.maxSteps,
timeout: argv.timeout,
disableTools: argv.disableTools,
enableAnalytics: argv.enableAnalytics,
enableEvaluation: argv.enableEvaluation,
domain: argv.domain,
evaluationDomain: argv.evaluationDomain,
toolUsageContext: argv.toolUsageContext,
domainAware: argv.domainAware,
context: processedContext,
contextConfig,
debug: argv.debug,
quiet: argv.quiet,
format: argv.format,
output: argv.output,
imageOutput: argv.imageOutput,
delay: argv.delay,
noColor: argv.noColor,
configFile: argv.configFile,
dryRun: argv.dryRun,
// TTS options
tts: argv.tts,
ttsVoice: argv.ttsVoice,
ttsProvider: argv.ttsProvider,
ttsFormat: argv.ttsFormat,
ttsSpeed: argv.ttsSpeed,
ttsQuality: argv.ttsQuality,
ttsOutput: argv.ttsOutput,
ttsPlay: argv.ttsPlay,
// STT options
stt: argv.stt,
sttProvider: argv.sttProvider,
sttLanguage: argv.sttLanguage,
inputAudio: argv.inputAudio,
// Video generation options (Veo 3.1)
outputMode: argv.outputMode,
videoProvider: argv.videoProvider,
videoOutput: argv.videoOutput,
videoResolution: argv.videoResolution,
videoLength: argv.videoLength,
videoAspectRatio: argv.videoAspectRatio,
videoAudio: argv.videoAudio,
// Avatar generation options
avatarProvider: argv.avatarProvider,
avatarImage: argv.avatarImage,
avatarAudio: argv.avatarAudio,
avatarText: argv.avatarText,
avatarVoice: argv.avatarVoice,
avatarQuality: argv.avatarQuality,
avatarFormat: argv.avatarFormat,
avatarOutput: argv.avatarOutput,
// Music generation options
musicProvider: argv.musicProvider,
musicDuration: argv.musicDuration,
musicFormat: argv.musicFormat,
musicGenre: argv.musicGenre,
musicMood: argv.musicMood,
musicTempo: argv.musicTempo,
musicOutput: argv.musicOutput,
// PPT generation options
pptPages: argv.pptPages,
pptTheme: argv.pptTheme,
pptAudience: argv.pptAudience,
pptTone: argv.pptTone,
pptOutput: argv.pptOutput,
pptAspectRatio: argv.pptAspectRatio,
pptNoImages: argv.pptNoImages,
// Extended thinking options for Claude and Gemini models
thinking: argv.thinking,
thinkingBudget: argv.thinkingBudget,
thinkingLevel: argv.thinkingLevel,
// Region option for cloud providers (Vertex AI, Bedrock, etc.)
region: argv.region,
// Anthropic subscription options
authMethod: argv.authMethod,
subscriptionTier: argv.subscriptionTier,
enableBeta: argv.enableBeta,
// Tool-routing flags ā constructor-level config, not a per-call option.
// Passed through the options bag so handlers can inject into the SDK
// instance before the first getOrCreateNeuroLink() call.
toolRouting: argv.toolRouting,
toolRoutingTimeout: argv.toolRoutingTimeout,
toolRoutingRouterProvider: argv.toolRoutingRouterProvider,
toolRoutingRouterModel: argv.toolRoutingRouterModel,
toolRoutingRouterRegion: argv.toolRoutingRouterRegion,
toolRoutingAlwaysInclude: argv.toolRoutingAlwaysInclude,
toolRoutingServers: argv.toolRoutingServers,
// Classifier-router flags ā constructor-level config (see note above).
classifierRouter: argv.classifierRouter,
classifierStrategy: argv.classifierStrategy,
classifierMinUpgradeConfidence: argv.classifierMinUpgradeConfidence,
classifierMinDowngradeConfidence: argv.classifierMinDowngradeConfidence,
classifierModelProvider: argv.classifierModelProvider,
classifierModelName: argv.classifierModelName,
classifierModelRegion: argv.classifierModelRegion,
classifierPool: argv.classifierPool,
classifierTimeout: argv.classifierTimeout,
// Skills flag ā constructor-level config (see note above).
skillsDir: argv.skillsDir,
};
}
/**
* Validate Anthropic subscription options
* Ensures subscription tier is provided when using anthropic-subscription provider
* or when oauth auth method is selected
*/
static validateAnthropicSubscriptionOptions(options) {
const provider = options.provider;
const authMethod = options.authMethod;
let subscriptionTier = options.subscriptionTier;
const enableBeta = options.enableBeta;
// Check if using anthropic-subscription provider or oauth auth method
const isSubscriptionMode = provider === "anthropic-subscription" || authMethod === "oauth";
if (isSubscriptionMode && !subscriptionTier) {
logger.always(chalk.yellow("ā ļø Subscription tier not specified. Defaulting to 'api' tier."));
logger.always(chalk.gray(" Use --subscription-tier to specify: free, pro, max, or api"));
options.subscriptionTier = "api";
subscriptionTier = "api";
}
// Validate oauth is required for non-api subscription tiers
if (subscriptionTier &&
["free", "pro", "max"].includes(subscriptionTier) &&
authMethod !== "oauth") {
logger.always(chalk.yellow(`ā ļø Subscription tier '${subscriptionTier}' typically uses OAuth authentication.`));
logger.always(chalk.gray(" Consider using --auth-method oauth for this tier."));
}
// Map anthropic-subscription to anthropic provider with subscription options
if (provider === "anthropic-subscription") {
options.provider = "anthropic";
options.useSubscription = true;
}
// Warn about beta features when enabled
if (enableBeta) {
logger.always(chalk.cyan("š§Ŗ Beta features enabled for Anthropic. Experimental capabilities may be unstable."));
}
// Build Anthropic auth configuration for provider initialization
if (provider === "anthropic" || provider === "anthropic-subscription") {
const authConfig = {
method: (authMethod === "oauth"
? "oauth"
: "api_key"),
subscriptionTier: subscriptionTier,
};
options.anthropicAuthConfig = authConfig;
options.enableBeta = enableBeta;
}
}
// Helper method to handle output
static handleOutput(result, options) {
let output;
if (options.format === "json") {
output = JSON.stringify(result, null, 2);
}
else if (options.format === "table" && Array.isArray(result)) {
logger.table(result);
return;
}
else {
if (typeof result === "string") {
output = result;
}
else if (result && typeof result === "object" && "content" in result) {
const generateResult = result;
output = generateResult.content;
// š§ Handle image generation output
if (generateResult.imageOutput?.base64 &&
generateResult.imageOutput.base64.trim().length > 0) {
try {
// Use custom path or default
let imagePath;
if (options.imageOutput) {
imagePath = path.resolve(options.imageOutput);
// Create parent directory if needed (cross-platform)
const dir = path.dirname(imagePath);
if (dir && dir !== "." && !fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
else {
const imageDir = "generated-images";
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const ext = imageExtensionFor(generateResult.imageOutput.mimeType, generateResult.imageOutput.base64);
imagePath = path.join(imageDir, `image-${timestamp}.${ext}`);
// Create directory if it doesn't exist
if (!fs.existsSync(imageDir)) {
fs.mkdirSync(imageDir, { recursive: true });
}
}
// Save image to file
const imageBuffer = Buffer.from(generateResult.imageOutput.base64, "base64");
fs.writeFileSync(imagePath, imageBuffer);
// Store image path in result for JSON output
generateResult.imageOutput.savedPath = imagePath;
// Always print image save confirmation - this is essential output
// (not suppressed by quiet flag since users need to know where the image was saved)
logger.always(`\nšø Generated image saved to: ${imagePath}`);
logger.always(` Image size: ${(imageBuffer.length / 1024).toFixed(2)} KB`);
}
catch (error) {
handleError(error, "Failed to save generated image");
}
}
// Add analytics display for text mode when enabled
if (options.enableAnalytics && generateResult.analytics) {
output +=
CLICommandFactory.formatAnalyticsForTextMode(generateResult);
}
}
else if (result && typeof result === "object" && "text" in result) {
output = result.text;
}
else {
output = JSON.stringify(result);
}
}
if (options.output) {
fs.writeFileSync(options.output, output);
if (!options.quiet) {
logger.always(`Output saved to ${options.output}`);
}
}
else {
logger.always(output);
}
}
/**
* Helper method to handle TTS audio file output and playback
* Saves audio to file when --tts-output flag is provided
* Plays audio when --tts-play flag is provided
*/
static async handleTTSOutput(result, options) {
const ttsOutputPath = options.ttsOutput;
const shouldPlay = options.ttsPlay;
// Nothing to do if neither save nor play is requested
if (!ttsOutputPath && !shouldPlay) {
return;
}
// Extract audio from result with proper type checking
if (!result || typeof result !== "object") {
return;
}
const generateResult = result;
const audio = generateResult.audio;
if (!audio) {
if (!options.quiet) {
logger.always(chalk.yellow("ā ļø No audio available in result. TTS may not be enabled for this request."));
}
return;
}
// Save audio to file if --tts-output is provided
if (ttsOutputPath) {
try {
const saveResult = await saveAudioToFile(audio, ttsOutputPath);
if (saveResult.success) {
if (!options.quiet) {
logger.always(chalk.green(`š Audio saved to: ${saveResult.path} (${formatFileSize(saveResult.size)})`));
}
}
else {
handleError(new Error(saveResult.error || "Failed to save audio file"), "TTS Output");
}
}
catch (error) {
handleError(error, "TTS Output");
}
}
// Play audio if --tts-play is provided
if (shouldPlay) {
try {
if (!options.quiet) {
logger.always(chalk.blue("Playing audio..."));
}
await playAudio(audio.buffer, audio.format);
}
catch (err) {
// Non-fatal: warn but don't crash
logger.always(chalk.yellow(`Audio playback failed: ${err.message}`));
logger.always(chalk.yellow(" Tip: Save the audio with --tts-output <file> and play manually."));
}
}
}
/**
* Helper method to configure options for video generation mode
* Auto-configures provider, model, and tools settings for video generation
*/
static configureVideoMode(enhancedOptions, argv, options) {
const userEnabledTools = !argv.disableTools; // Tools are enabled by default
enhancedOptions.disableTools = true;
// Resolve video provider from explicit --videoProvider first, then top-level --provider, then default to vertex.
if (!enhancedOptions.videoProvider) {
enhancedOptions.videoProvider =
enhancedOptions.provider ?? "vertex";
if (options.debug) {
logger.debug(`Auto-setting video provider to '${enhancedOptions.videoProvider}' for video generation mode`);
}
}
// Auto-set model to veo-3.1 if not explicitly specified
if (!enhancedOptions.model) {
// Resolve the alias to the full model ID for Vertex AI
const modelAlias = "veo-3.1";
const resolvedModel = ModelResolver.resolveModel(modelAlias);
const fullModelId = resolvedModel?.id || "veo-3.1-generate-001";
enhancedOptions.model = fullModelId;
if (options.debug) {
logger.debug(`Auto-setting model to '${fullModelId}' for video generation mode`);
}
}
// Warn user if they explicitly enabled tools
if (userEnabledTools && !options.quiet) {
logger.always(chalk.yellow("ā ļø Note: MCP tools are not supported in video generation mode and have been disabled."));
}
if (options.debug) {
logger.debug("Video generation mode enabled (tools auto-disabled):", {
provider: enhancedOptions.provider,
model: enhancedOptions.model,
resolution: enhancedOptions.videoResolution,
length: enhancedOptions.videoLength,
aspectRatio: enhancedOptions.videoAspectRatio,
audio: enhancedOptions.videoAudio,
outputPath: enhancedOptions.videoOutput,
});
}
}
/**
* Helper method to configure options for PPT generation mode
* Auto-configures provider, model, and tools settings for presentation generation
*/
static configurePPTMode(enhancedOptions, argv, options) {
const userEnabledTools = !argv.disableTools; // Tools are enabled by default
enhancedOptions.disableTools = true;
// Auto-set provider for PPT generation if not explicitly specified
// PPT works best with Vertex or Google AI for content planning
if (!enhancedOptions.provider) {
enhancedOptions.provider = "vertex";
if (options.debug) {
logger.debug("Auto-setting provider to 'vertex' for PPT generation mode");
}
}
// Auto-set model if not explicitly specified
if (!enhancedOptions.model) {
// Use gemini-2.5-flash for fast, high-quality content planning
const modelAlias = "gemini-2.5-flash";
const resolvedModel = ModelResolver.resolveModel(modelAlias);
const fullModelId = resolvedModel?.id || "gemini-2.5-flash-001";
enhancedOptions.model = fullModelId;
if (options.debug) {
logger.debug(`Auto-setting model to '${fullModelId}' for PPT generation mode`);
}
}
// Warn user if they explicitly enabled tools
if (userEnabledTools && !options.quiet) {
logger.always(chalk.yellow("ā ļø Note: MCP tools are not supported in PPT generation mode and have been disabled."));
}
if (options.debug) {
logger.debug("PPT generation mode enabled (tools auto-disabled):", {
provider: enhancedOptions.provider,
model: enhancedOptions.model,
pages: enhancedOptions.pptPages,
theme: enhancedOptions.pptTheme,
audience: enhancedOptions.pptAudience,
tone: enhancedOptions.pptTone,
aspectRatio: enhancedOptions.pptAspectRatio,
noImages: enhancedOptions.pptNoImages,
outputPath: enhancedOptions.pptOutput,
});
}
}
/**
* Helper method to handle video file output
* Saves generated video to file when --videoOutput flag is provided
*/
static async handleVideoOutput(result, options) {
// Check if --videoOutput flag is provided
const videoOutputPath = options.videoOutput;
if (!videoOutputPath) {
return;
}
// Extract video from result with proper type checking
if (!result || typeof result !== "object") {
return;
}
const generateResult = result;
const video = generateResult.video;
if (!video) {
if (!options.quiet) {
logger.always(chalk.yellow("ā ļø No video available in result. Video generation may not be enabled or the request failed."));
}
return;
}
try {
// Save video to file
const saveResult = await saveVideoToFile(video, videoOutputPath);
if (saveResult.success) {
const sizeInfo = formatVideoFileSize(saveResult.size);
const metadataSummary = getVideoMetadataSummary(video);
logger.always(chalk.green(`š¬ Video saved to: ${saveResult.path} (${sizeInfo})`));
if (!options.quiet && metadataSummary) {
logger.always(chalk.gray(` ${metadataSummary}`));
}
}
else {
handleError(new Error(saveResult.error || "Failed to save video file"), "Video Output");
}
}
catch (error) {
handleError(error, "Video Output");
}
}
/**
* Helper method to handle avatar video file output.
* Saves the generated avatar buffer to --avatarOutput path when provided.
*/
static async handleAvatarOutput(result, options) {
const avatarOutputPath = options.avatarOutput;
if (!avatarOutputPath) {
return;
}
if (!result || typeof result !== "object") {
return;
}
const generateResult = result;
const avatar = generateResult.avatar;
if (!avatar) {
if (!options.quiet) {
logger.always(chalk.yellow("ā ļø No avatar video available in result. Avatar generation may not be enabled or the request failed."));
}
return;
}
try {
fs.writeFileSync(avatarOutputPath, avatar.buffer);
if (!options.quiet) {
const sizeStr = formatFileSize(avatar.size);
logger.always(chalk.green(`š¤ Avatar video saved to: ${avatarOutputPath} (${sizeStr})`));
}
}
catch (error) {
handleError(error, "Avatar Output");
}
}
/**
* Helper method to handle music audio file output.
* Saves the generated music buffer to --musicOutput path when provided.
*/
static async handleMusicOutput(result, options) {
const musicOutputPath = options.musicOutput;
if (!musicOutputPath) {
return;
}
if (!result || typeof result !== "object") {
return;
}
const generateResult = result;
const music = generateResult.music;
if (!music) {
if (!options.quiet) {
logger.always(chalk.yellow("ā ļø No music available in result. Music generation may not be enabled or the request failed."));
}
return;
}
try {
fs.writeFileSync(musicOutputPath, music.buffer);
if (!options.quiet) {
const sizeStr = formatFileSize(music.size);
logger.always(chalk.green(`šµ Music saved to: ${musicOutputPath} (${sizeStr})`));
}
}
catch (error) {
handleError(error, "Music Output");
}
}
/**
* Helper method to handle PPT file output
* Displays PPT generation result info
*/
static async handlePPTOutput(result, options) {
// Extract PPT from result with proper type checking
if (!result || typeof result !== "object") {
return;
}
const generateResult = result;
const ppt = generateResult.ppt;
if (!ppt) {
// PPT not in result - either not PPT mode or generation failed
return;
}
try {
if (options.quiet) {
if (ppt.filePath) {
logger.always(chalk.green(`š Presentation saved to: ${ppt.filePath}`));
}
else {
logger.always(chalk.green("š Presentation generated successfully."));
}
if (ppt.totalSlides) {
logger.always(chalk.white(`š Slides: ${ppt.totalSlides}`));
}
return;
}
logger.always(chalk.green("\nš Presentation Generated Successfully!"));
logger.always(chalk.gray("ā".repeat(50)));
if (ppt.filePath) {
logger.always(chalk.white(` š File: ${ppt.filePath}`));
}
if (ppt.totalSlides) {
logger.always(chalk.white(` š Slides: ${ppt.totalSlides}`));
}
if (ppt.format) {
logger.always(chalk.white(` š Format: ${ppt.format.toUpperCase()}`));
}
logger.always(chalk.gray("ā".repeat(50)));
logger.always(chalk.cyan("š” Tip: Open the file with PowerPoint or Google Slides to view."));
}
catch (error) {
handleError(error, "PPT Output");
}
}
// Helper method to validate token usage data with fallback handling
static isValidTokenUsage(tokens) {
if (!tokens || typeof tokens !== "object" || tokens === null) {
return false;
}
const tokensObj = tokens;
// Check primary format: analytics.tokens {input, output, total}
if (typeof tokensObj.input === "number" &&
typeof tokensObj.output === "number" &&
typeof tokensObj.total === "number") {
return true;
}
// Check fallback format: tokenUsage {inputTokens, outputTokens, totalTokens}
if (typeof tokensObj.inputTokens === "number" &&
typeof tokensObj.outputTokens === "number" &&
typeof tokensObj.totalTokens === "number") {
return true;
}
return false;
}
// Helper method to normalize token usage data to standard format
static normalizeTokenUsage(tokens) {
if (!CLICommandFactory.isValidTokenUsage(tokens)) {
return null;
}
const tokensObj = tokens;
// Primary format: analytics.tokens {input, output, total}
if (typeof tokensObj.input === "number" &&
typeof tokensObj.output === "number" &&
typeof tokensObj.total === "number") {
return {
input: tokensObj.input,
output: tokensObj.output,
total: tokensObj.total,
};
}
// Fallback format: tokenUsage {inputTokens, outputTokens, totalTokens}
if (typeof tokensObj.inputTokens === "number" &&
typeof tokensObj.outputTokens === "number" &&
typeof tokensObj.totalTokens === "number") {
return {
input: tokensObj.inputTokens,
output: tokensObj.outputTokens,
total: tokensObj.totalTokens,
};
}
return null;
}
// Helper method to format analytics for text mode display
static formatAnalyticsForTextMode(result) {
if (!result.analytics) {
return "";
}
const analytics = result.analytics;
let analyticsText = "\n\nš Analytics:\n";
// Provider and model info
analyticsText += ` Provider: ${analytics.provider}`;
// Check for model in multiple locations: result.model, analytics.model, or available model property
const modelName = result.model ||
analytics.model ||
analytics.modelName;
if (modelName) {
analyticsText += ` (${modelName})`;
}
analyticsText += "\n";
// Token usage with fallback handling
const normalizedTokens = CLICommandFactory.normalizeTokenUsage(analytics.tokenUsage);
if (normalizedTokens) {
analyticsText += ` Tokens: ${normalizedTokens.input} input + ${normalizedTokens.output} output = ${normalizedTokens.total} total\n`;
}
// Cost information
if (analytics.cost !== undefined &&
analytics.cost !== null &&
typeof analytics.cost === "number") {
analyticsText += ` Cost: $${analytics.cost.toFixed(5)}\n`;
}
// Response time with fallback handling for requestDuration vs responseTime
const analyticsRecord = analytics;
const duration = analytics.requestDuration ||
analyticsRecord.responseTime ||
analyticsRecord.duration;
if (duration && typeof duration === "number") {
const timeInSeconds = (duration / 1000).toFixed(1);
analyticsText += ` Time: ${timeInSeconds}s\n`;
}
// Tools used
if (result.toolsUsed && result.toolsUsed.length > 0) {
analyticsText += ` Tools: ${result.toolsUsed.join(", ")}\n`;
}
// Context information
if (analytics.context &&
typeof analytics.context === "object" &&
analytics.context !== null) {
const contextEntries = Object.entries(analytics.context);
if (contextEntries.length > 0) {
const contextItems = contextEntries.map(([key, value]) => `${key}=${value}`);
analyticsText += ` Context: ${contextItems.join(", ")}\n`;
}
}
return analyticsText;
}
/**
* Create the new primary 'generate' command
*/
static createGenerateCommand() {
return {
command: ["generate [input]", "gen [input]"],
describe: "Generate content using AI providers",
builder: (yargs) => {
return CLICommandFactory.buildOptions(yargs
.positional("input", {
type: "string",
description: "Text prompt for AI generation (or read from stdin)",
})
.example('$0 generate "Explain quantum computing"', "Basic generation")
.example('$0 gen "Write a Python function" --provider openai', "Use specific provider")
.example('$0 generate "Code review" -m gpt-4 -t 0.3', "Use specific model and temperature")
.example('echo "Summarize this" | $0 generate', "Use stdin input")
.example('$0 generate "Analyze data" --enable-analytics', "Enable usage analytics")
.example('$0 generate "Futuristic city" --model gemini-2.5-flash-image', "Generate an image")
.example('$0 generate "Mountain landscape" --model gemini-2.5-flash-image --imageOutput ./my-images/mountain.png', "Generate image with custom path")
.example('$0 generate "Describe this video" --video path/to/video.mp4', "Analyze video content")
.example('$0 generate "Analyze sales" --csv data.csv --csv-format raw', "CSV with raw format (fast, minimal tokens)")
.example('$0 generate "Summarize data" --csv small.csv --csv-format markdown', "CSV with markdown table (readable)")
.example('$0 generate "Process data" --csv records.csv --csv-format json', "CSV with JSON format (structured)")
.example('$0 generate "Product showcase video" --image ./product.jpg --outputMode video --videoOutput ./output.mp4', "Generate video from image")
.example('$0 generate "Smooth camera movement" --image ./input.jpg --provider vertex --model veo-3.1-generate-001 --outputMode video --videoResolution 720p --videoLength 6 --videoAspectRatio 16:9 --videoOutput ./output.mp4', "Video generation with full options")
.example('$0 generate "AI in Healthcare" --pptPages 10', "Generate a PowerPoint presentation")
.example('$0 generate "Company Q4 Results" --pptPages 15 --pptTheme corporate --pptAudience business', "Generate presentation with options")
.example('$0 generate "Machine Learning 101" --pptTheme minimal --pptTone educational --pptNoImages', "Generate educational slides without AI images")
.example('$0 generate "Describe this image" --image ./photo.jpg', "Analyze an image (multimodal input)")
.example('$0 generate "Summarize this report" --pdf ./report.pdf', "Analyze a PDF document")
.example('$0 generate "Key trends?" --csv large-data.csv --csvMaxRows 100', "Analyze a CSV with a row limit")
.example('$0 generate "Compare the chart and the report" --image ./chart.png --pdf ./report.pdf', "Combine multiple file types in one prompt")
.example('$0 generate "What is in this file?" --file ./data.json', "Auto-detect a file type with --file")
.example('$0 generate "Fix the failing config" --agent-mode --max-steps 60', "Run as an autonomous terminal agent with a step budget"), CLICommandFactory.agentOptions);
},
handler: async (argv) => await CLICommandFactory.executeGenerate(argv),
};
}
/**
* Create stream command
*/
static createStreamCommand() {
return {
command: "stream [input]",
describe: "Stream generation in real-time",
builder: (yargs) => {
return CLICommandFactory.buildOptions(yargs
.positional("input", {
type: "string",
description: "Text prompt for streaming (or read from stdin)",
})
.example('$0 stream "Write a story about space"', "Stream a creative story")
.example('$0 stream "Explain machine learning" -p anthropic', "Stream with specific provider")
.example('$0 stream "Code walkthrough" --output story.txt', "Stream to file")
.example('echo "Live demo" | $0 stream', "Stream from stdin")
.example('$0 stream "Narrate this video" --video path/to/video.mp4', "Stream video analysis")
.example('$0 stream "Describe this image" --image ./photo.jpg', "Stream image analysis (multimodal input)")
.example('$0 stream "Summarize this document" --pdf ./report.pdf', "Stream PDF analysis")
.example('$0 stream "Explain this dataset" --csv ./data.csv --csv-format markdown', "Stream CSV analysis"), CLICommandFactory.agentOptions);
},
handler: async (argv) => await CLICommandFactory.executeStream(argv),
};
}
/**
* Create batch command
*/
static createBatchCommand() {
return {
// #1191 round-5: the positional is named `promptsFile`, not `file` ā
// yargs implicitly accepts `--<positionalName>` as a flag alias for
// any positional regardless of whether it's separately registered via
// `.options()`, so a positional literally named `file` would let
// `--file` silently pass strictOptions() even after removing
// `commonOptions.file` below. Renaming the key (invocation syntax is
// unaffected ā it's still just `neurolink batch prompts.txt`) is what
// actually makes `--file` an unknown argument.
command: "batch <promptsFile>",
describe: "Process multiple prompts from a file",
builder: (yargs) => {
return CLICommandFactory.buildOptions(yargs
.positional("promptsFile", {
type: "string",
description: "File with prompts (one per line)",
demandOption: true,
})
.example("$0 batch prompts.txt", "Process prompts from file")
.example("$0 batch questions.txt --format json", "Export results as JSON")
.example("$0 batch tasks.txt -p vertex --delay 2000", "Use Vertex AI with 2s delay")
.example("$0 batch batch.txt --output results.json", "Save results to file"), {},
// `--file` is not a batch flag ā the positional above is the
// prompts-list path. Omit it from registration so it's rejected
// as unknown rather than silently accepted then ignored.
["file"]);
},
handler: async (argv) => await CLICommandFactory.executeBatch(argv),
};
}
/**
* Create provider commands
*/
static createProviderCommands() {
return {
command: "provider <subcommand>",
describe: "Manage AI provider configurations and status",
builder: (yargs) => {
return yargs
.command("status", "Check status of all configured AI providers", (y) => CLICommandFactory.buildOptions(y)
.example("$0 provider status", "Check all provider status")
.example("$0 provider status --verbose", "Detailed provider diagnostics")
.example("$0 provider status --quiet", "Minimal status output"), (argv) => CLICommandFactory.executeProviderStatus(argv))
.demandCommand(1, "Please specify a provider subcommand");
},
handler: () => { }, // No-op handler as subcommands handle everything
};
}
/**
* Create status command (alias for provider status)
*/
static createStatusCommand() {
return {
command: "status",
describe: "Check AI provider connectivity and performance (alias for provider status)",
builder: (yargs) => CLICommandFactory.buildOptions(yargs)
.example("$0 status", "Quick provider status check")
.example("$0 status --verbose", "Detailed connectivity diagnostics")
.example("$0 status --format json", "Export status as JSON"),
handler: async (argv) => await CLICommandFactory.executeProviderStatus(argv),
};
}
/**
* Create models commands
*/
static createModelsCommands() {
return ModelsCommandFactory.createModelsCommands();
}
/**
* Create MCP commands
*/
static createMCPCommands() {
return MCPCommandFactory.createMCPCommands();
}
/**
* Create discover command
*/
static createDiscoverCommand() {
return MCPCommandFactory.createDiscoverCommand();
}
/**
* Create agent commands for multi-agent orchestration
*/
static createAgentCommands() {
return AgentCommandFactory.createAgentCommands();
}
/**
* Create network commands for agent network orchestration
*/
static createNetworkCommands() {
return AgentCommandFactory.createNetworkCommands();
}
/**
* Create memory commands
*/
static createMemoryCommands() {
return {
command: "memory <subcommand>",
describe: "Manage conversation memory and session history",
builder: (yargs) => {
return yargs
.command("list", "List all conversation sessions with metadata", (y) => CLICommandFactory.buildOptions(y)
.option("user-id", {
type: "string",
description: "User ID to filter sessions (required for Redis)",
alias: "u",
})
.example("$0 memory list", "List all sessions")
.example("$0 memory list --format json", "List sessions as JSON")
.example("$0 memory list --user-id user123", "List sessions for specific user"), async (argv) => await CLICommandFactory.executeMemoryList(argv))
.command("export", "Export a specific session to JSON/CSV", (y) => CLICommandFactory.buildOptions(y)
.option("session-id", {
type: "string",
description: "Session ID to export",
demandOption: true,
alias: "s",
})
.option("include-metadata", {
type: "boolean",
default: false,
description: "Include export metadata",
})
// Override the generic --format so this command accepts csv.
// (Alias -f / --output-format comes from the common options.)
.option("format", {
choices: ["json", "csv"],
default: "json",
description: "Export format (json or csv)",
})
.example("$0 memory export --session-id session-123", "Export session to stdout")
.example("$0 memory export --session-id session-123 --format json > history.json", "Export to file")
.example("$0 memory export --session-id session-123 --format csv > history.csv", "Export as CSV")
.example("$0 memory export --session-id session-123 --include-metadata", "Export with metadata"), async (argv) => await CLICommandFactory.executeMemoryExport(argv))
.command("export-all", "Export all sessions to a directory", (y) => CLICommandFactory.buildOptions(y)
.option("output", {
type: "string",
description: "Output directory for exported files",
default: "./memory-exports",
alias: "o",
})
.option("user-id", {
type: "string",
description: "User ID to filter sessions (required for Redis)",
alias: "u",
})
.option("include-metadata", {
type: "boolean",
default: true,
description: "Include export metadata",
})
// Override the generic --format so this command accepts csv;
// it controls the extension/serialization of the per-session
// files written to the output directory. (Alias -f /
// --output-format comes from the common options.)
.option("format", {
choices: ["json", "csv"],
default: "json",
description: "Per-file export format (json or csv)",
})
.example("$0 memory export-all --output ./exports/", "Export all sessions to directory")
.example("$0 memory export-all --user-id user123 --output ./user-exports/", "Export all sessions for user")
.example("$0 memory export-all --output ./exports/ --format csv", "Export all sessions as CSV files"), async (argv) => await CLICommandFactory.executeMemoryExportAll(argv))
.command("delete", "Delete a specific conversation session", (y) => CLICommandFactory.buildOptions(y)
.option("session-id", {
type: "string",
description: "Session ID to delete",
demandOption: true,
alias: "s",
})
.option("force", {
type: "boolean",
default: false,
description: "Skip confirmation prompt",
alias: "f",
})
.example("$0 memory delete --session-id session-123", "Delete session with confirmation")
.example("$0 memory delete --session-id session-123 --force", "Delete session without confirmation"), async (argv) => await CLICommandFactory.executeMemoryDelete(argv))
.command("stats", "Show conversation memory statistics", (y) => CLICommandFactory.buildOptions(y)
.example("$0 memory stats", "Show memory usage statistics")
.example("$0 memory stats --format json", "Export stats as JSON"), async (argv) => await CLICommandFactory.executeMemoryStats(argv))
.command("history <sessionId>", "Show conversation history for a session", (y) => CLICommandFactory.buildOptions(y)
.positional("sessionId", {
type: "string",
description: "Session ID to retrieve history for",
demandOption: true,
})
.example("$0 memory history session-123", "Show conversation history")
.example("$0 memory history session-123 --format json", "Export history as JSON"), async (argv) => await CLICommandFactory.executeMemoryHistory(argv))
.command("clear [sessionId]", "Clear conversation history (use --confirm to clear all)", (y) => CLICommandFactory.buildOptions(y)
.positional("sessionId", {
type: "string",
description: "Session ID to clear (omit to clear all sessions)",
demandOption: false,
})
.option("confirm", {
type: "boolean",
default: false,
description: "Confirm clearing all sessions",
})
.example("$0 memory clear --confirm", "Clear all conversation history")
.example("$0 memory clear session-123", "Clear specific session"), async (argv) => await CLICommandFactory.executeMemoryClear(argv))
.demandCommand(1, "Please specify a memory subcommand");
},
handler: () => { }, // No-op handler as subcommands handle everything
};
}
/**
* Create skills commands ā manage the local skills store
* (list / show / search / create / delete). The store directory comes
* from --skills-dir, NEUROLINK_SKILLS_DIR, or defaults to ./skills.
*/
static createSkillsCommands() {
return {
command: "skills <subcommand>",
describe: "Manage skills (SOPs/playbooks the AI can follow)",
builder: (yargs) => {
return yargs
.command("list", "List all active skills (index only, no instructions)", (y) => CLICommandFactory.buildOptions(y)
.example("$0 skills list --skills-dir ./skills", "List skills")
.example("$0 skills list --format json", "Export as JSON"), async (argv) => await CLICommandFactory.executeSkillsList(argv))
.command("show <skill>", "Show one skill including its full instructions", (y) => CLICommandFactory.buildOptions(y)
.positional("skill", {
type: "string",
description: "Skill id or exact name",
demandOption: true,
})
.example("$0 skills show refund_dispute_escalation", "Show a skill by name"), async (argv) => await CLICommandFactory.executeSkillsShow(argv))
.command("search <query>", "Search skills by keyword (matches name and description)", (y) => CLICommandFactory.buildOptions(y)
.positional("query", {
type: "string",
description: "Keyword to match",
demandOption: true,
})
.option("tag", {
type: "string",
description: "Tag filter applied on top of the keyword",
})
.example('$0 skills search "refund"', "Find refund skills"), async (argv) => await CLICommandFactory.executeSkillsSearch(argv))
.command("create", "Create a new skill in the store", (y) => CLICommandFactory.buildOptions(y)
.option("name", {
type: "string",
description: "Machine-friendly unique name (snake_case)",
demandOption: true,
})
.option("description", {
type: "string",
description: "When this skill applies (used for matching)",
demandOption: true,
})
.option("instructions", {
type: "string",
description: "Full instructions text",
})
.option("instructions-file", {
type: "string",
description: "Read instructions from a file",
})
.option("display-name", {
type: "string",
description: "Human-readable display name",
})
.option("tags", {
type: "array",
string: true,
description: "Domain tags",
})
.example('$0 skills create --name deploy_sop --description "How to deploy" --instructions-file ./sop.md', "Create from a file"), async (argv) => await CLICommandFactory.executeSkillsCreate(argv))
.command("delete <skill>", "Soft-delete (deprecate) a skill", (y) => CLICommandFactory.buildOptions(y).positional("skill", {
type: "string",
description: "Skill id or exact name",
demandOption: true,
}), async (argv) => await CLICommandFactory.executeSkillsDelete(argv))
.demandCommand(1, "Please specify a skills subcommand");
},
handler: () => { }, // No-op handler as subcommands handle everything
};
}
/**
* Create config commands
*/
static createConfigCommands() {
return {
command: "config <subcommand>",
describe: "Manage NeuroLink configuration",
builder: (yargs) => {
return yargs
.command("init", "Interactive configuration setup wizard", (y) => CLICommandFactory.buildOptions(y), async (_argv) => {
await configManager.initInteractive();
})
.command("show", "Display current configuration", (y) => CLICommandFactory.buildOptions(y), async (_argv) => {
configManager.showConfig();
})
.command("validate", "Validate current configuration", (y) => CLICommandFactory.buildOptions(y), async (_argv) => {
const result = configManager.validateConfig();
if (result.valid) {
logger.always(chalk.green("ā
Configuration is valid"));
}
else {
const errorMessages = result.errors.join("\n ⢠");
handleError(new Error(`Configuration has errors:\n ⢠${errorMessages}`), "Configuration validation");
}
})
.command("reset", "Reset configuration to defaults", (y) => CLICommandFactory.buildOptions(y), async (_argv) => {
configManager.resetConfig();
})
.command("export", "Export current configuration", (y) => CLICommandFactory.buildOptions(y), (argv) => CLICommandFactory.executeConfigExport(argv))
.demandCommand(1, "");
},
handler: () => { }, // No-op handler as subcommands handle everything
};
}
/**
* Create validate command
*/
static createValidateCommand() {
return {
command: "validate",
describe: "Validate current configuration (alias for 'config validate')",
builder: (yargs) => CLICommandFactory.buildOptions(yargs),
handler: async (_argv) => {
const result = configManager.validateConfig();
if (result.valid) {
logger.always(chalk.green("ā
Configuration is valid"));
}
else {
const errorMessages = result.errors.join("\n ⢠");
handleError(new Error(`Configuration has errors:\n ⢠${errorMessages}`), "Configuration validation");
}
},
};
}
/**
* Create get-best-provider command
*/
static createBestProviderCommand() {
return {
command: "get-best-provider",
describe: "Show the best available AI provider",
builder: (yargs) => CLICommandFactory.buildOptions(yargs)
.example("$0 get-best-provider", "Get best available provider")
.example("$0 get-best-provider --format json", "Get provider as JSON")
.example("$0 get-best-provider --quiet", "Just the provider name"),
handler: async (argv) => await CLICommandFactory.executeGetBestProvider(argv),
};
}
/**
* Create Ollama commands
*/
static createOllamaCommands() {
return OllamaCommandFactory.createOllamaCommands();
}
/**
* Create setup command
*/
static createSetupCommand() {
return {
command: ["setup [provider]", "s [provider]"],
describe: "Interactive AI provider setup wizard",
builder: (yargs) => {
return CLICommandFactory.buildOptions(yargs
.positional("provider", {
type: "string",
description: "Specific provider to set up",
choices: [
"google-ai",
"openai",
"openrouter",
"anthropic",
"anthropic-subscription", // Setup Anthropic with subscription tier
"azure",
"bedrock",
"vertex",
"huggingface",
"mistral",
"deepseek",
"nvidia-nim",
"lm-studio",
"llamacpp",
"xai",
"groq",
"cerebras",
"cohere",
"together-ai",
"fireworks",
"perplexity",
"cloudflare",
"replicate",
"voyage",
"jina",
"stability",
"ideogram",
"recraft",
],
})
.option("list", {
type: "boolean",
description: "List all available providers",
alias: "l",
})
.option("status", {
type: "boolean",
description: "Show provider configuration status",
})
.option("subscription-tier", {
type: "string",
choices: ["free", "pro", "max", "max_5", "max_20", "api"],
description: "Anthropic subscription tier for setup (free, pro, max, max_5, max_20, api)",
})
.option("auth-method", {
type: "string",
choices: ["api-key", "oauth"],
description: "Authentication method for Anthropic (api-key or oauth)",
})
.example("$0 setup", "Interactive setup wizard")
.example("$0 setup --provider openai", "Setup specific provider")
.example("$0 setup --provider anthropic --subscription-tier pro", "Setup Anthropic with Pro subscription")
.example("$0 setup --provider anthropic --auth-method oauth", "Setup Anthropic with OAuth authentication")
.example("$0 setup --list", "List all providers")
.example("$0 setup --status", "Check provider status"));
},
handler: async (argv) => await handleSetup(argv),
};
}
/**
* Create SageMaker commands
*/
static createSageMakerCommands() {
return SageMakerCommandFactory.createSageMakerCommands();
}
/**
* Create completion command
*/
/**
* Create loop command
*/
static createLoopCommand() {
return {
command: "loop",
describe: "Start an interactive loop session with conversation management",
builder: (yargs) => CLICommandFactory.buildOptions(yargs, {
"enable-conversation-memory": {
type: "boolean",
description: "Enable conversation memory for the loop session",
default: true,
},
"max-sessions": {
type: "number",
description: "Maximum number of conversation sessions to keep",
default: 50,
},
"max-turns-per-session": {
type: "number",
description: "Maximum turns per conversation session",
default: 20,
},
"auto-redis": {
type: "boolean",
description: "Automatically use Redis if available",
default: true,
},
resume: {
type: "string",
description: "Directly resume a specific conversation by session ID",
alias: "r",
},
new: {
type: "boolean",
description: "Force start a new conversation (skip selection menu)",
alias: "n",
},
"list-conversations": {
type: "boolean",
description: "List available conversations and exit",
alias: "l",
},
"compact-threshold": {
describe: "Context compaction trigger threshold (0.0-1.0)",
type: "number",
default: 0.8,
},
"disable-compaction": {
describe: "Disable automatic context compaction",
type: "boolean",
default: false,
},
})
.example("$0 loop", "Start interactive session with conversation selection")
.example("$0 loop --new", "Force start new conversation")
.example("$0 loop --resume abc123", "Resume specific conversation")
.example("$0 loop --list-conversations", "List available conversations")
.example("$0 loop --no-auto-redis", "Use in-memory storage only")
.example("$0 loop --enable-conversation-memory", "Start loop with memory"),
handler: async (argv) => {
if (globalSession.getCurrentSessionId()) {
logger.error("A loop session is already active. Cannot start a new one.");
return;
}
let conversationMemoryConfig;
const { enableConversationMemory, maxSessions, maxTurnsPerSession, autoRedis, listConversations, compactThreshold, disableCompaction, } = argv;
if (enableConversationMemory) {
let storageType = "memory";
if (autoRedis) {
const isRedisAvailable = await checkRedisAvailability();
if (isRedisAvailable) {
storageType = "redis";
if (!argv.quiet) {
logger.always(chalk.green("ā
Using Redis for persistent conversation memory"));
}
}
else if (argv.debug) {
logger.debug("Redis not available, using in-memory storage");
}
}
else if (argv.debug) {
logger.debug("Auto-Redis disabled, using in-memory storage");
}
process.env.STORAGE_TYPE = storageType;
conversationMemoryConfig = {
enabled: true,
maxSessions: maxSessions,
maxTurnsPerSession: maxTurnsPerSession,
contextCompaction: {
enabled: !disableCompaction,
threshold: compactThreshold,
},
};
}
// Inject skills config (--skills-dir / NEUROLINK_SKILLS_DIR) before
// the loop session constructs its NeuroLink instance.
const loopSkillsConfig = buildSkillsConfigFromCli(argv);
if (loopSkillsConfig) {
globalSession.setSkillsConfig(loopSkillsConfig);
}
// Handle --list-conversations option
if (listConversations) {
const { ConversationSelector } = await import("../loop/conversationSelector.js");
const conversationSelector = new ConversationSelector();
try {
const hasConversations = await conversationSelector.hasStoredConversations();
if (!hasConversations) {
logger.always(chalk.yellow("š No stored conversations found"));
return;
}
const conversations = await conversationSelector.getAvailableConversations();
logger.always(chalk.blue("š Available Conversations:"));
conversations.forEach((conv, index) => {
const sessionId = conv.sessionId.slice(0, 12) + "...";
const title = conv.title || "Untitled Conversation";
const messageCount = conv.messageCount || 0;
const lastActivity = conv.updatedAt
? new Date(conv.updatedAt).toLocaleDateString()
: "Unknown";
logger.always(`${index + 1}. ${chalk.cyan(sessionId)} - ${title}`);
logger.always(` ${chalk.gray(`${messageCount} messages | Last: ${lastActivity}`)}`);
});
logger.always(chalk.gray(`\nUse: neurolink loop --resume <session-id> to resume a conversation`));
}
catch (error) {
logger.error("Failed to list conversations:", error);
}
finally {
await conversationSelector.close();
}
return;
}
// Create enhanced session with direct session management options
const sessionOptions = {};
// Pass CLI options to session for direct session management
if (argv.resume && typeof argv.resume === "string") {
sessionOptions.directResumeSessionId = argv.resume;
}
if (argv.new) {
sessionOptions.forceNewSession = true;
}
const session = new LoopSession(initializeCliParser, conversationMemoryConfig, sessionOptions);
await session.start();
},
};
}
/**
* Create completion command
*/
static createCompletionCommand() {
return {
command: "completion",
describe: "Generate shell completion script",
builder: (yargs) => CLICommandFactory.buildOptions(yargs)
.example("$0 completion", "Generate shell completion")
.example("$0 completion > ~/.neurolink-completion.sh", "Save completion script")
.example("source ~/.neurolink-completion.sh", "Enable completions (bash)")
.epilogue("Add the completion script to your shell profile for persistent completions"),
handler: async (argv) => await CLICommandFactory.executeCompletion(argv),
};
}
/**
* Execute provider status command
*/
static async executeProviderStatus(argv) {
if (argv.verbose && !argv.quiet) {
logger.always(chalk.yellow("ā¹ļø Verbose mode enabled. Displaying detailed status.\n"));
}
const spinner = argv.quiet
? null
: ora("š Checking AI provider status...\n").start();
const sdk = globalSession.getOrCreateNeuroLink();
try {
// Handle dry-run mode for provider status
if (argv.dryRun) {
const mockResults = [
{
provider: "google-ai",
status: "working",
configured: true,
requestDuration: 150,
model: "gemini-2.5-flash",
},
{
provider: "openai",
status: "working",
configured: true,
requestDuration: 200,
model: "gpt-4o-mini",
},
{
provider: "anthropic",
status: "working",
configured: true,
requestDuration: 180,
model: "claude-3-haiku",
},
{ provider: "bedrock", status: "not configured", configured: false },
{ provider: "vertex", status: "not configured", configured: false },
];
if (spinner) {
spinner.succeed("Provider check complete (dry-run): 3/3 providers working");
}
// Display mock results
for (const result of mockResults) {
const status = result.status === "working"
? chalk.green("ā
Working")
: result.status === "failed"
? chalk.red("ā Failed")
: chalk.gray("āŖ Not configured");
const time = result.requestDuration
? ` (${result.requestDuration}ms)`
: "";
const model = result.model ? ` [${result.model}]` : "";
logger.always(`${result.provider}: ${status}${time}${model}`);
}
if (argv.verbose && !argv.quiet) {
logger.always(chalk.blue("\nš Detailed Results (Dry-run):"));
logger.always(JSON.stringify(mockResults, null, 2));
}
return;
}
// Use SDK's provider diagnostic method instead of manual testing
const results = await sdk.getProviderStatus({ quiet: !!argv.quiet });
if (spinner) {
const working = results.filter((r) => r.status === "working").length;
const configured = results.filter((r) => r.configured).length;
spinner.succeed(`Provider check complete: ${working}/${configured} providers working`);
}
// Display results
for (const result of results) {
const status = result.status === "working"
? chalk.green("ā
Working")
: result.status === "failed"
? chalk.red("ā Failed")
: chalk.gray("āŖ Not configured");
const time = result.responseTime ? ` (${result.responseTime}ms)` : "";
const model = result.model ? ` [${result.model}]` : "";
logger.always(`${result.provider}: ${status}${time}${model}`);
if (argv.verbose && result.error) {
logger.always(` Error: ${chalk.red(result.error)}`);
}
}
if (argv.verbose && !argv.quiet) {
logger.always(chalk.blue("\nš Detailed Results:"));
logger.always(JSON.stringify(results, null, 2));
}
}
catch (error) {
if (spinner) {
spinner.fail("Provider status check failed");
}
handleError(error, "Provider status check");
}
finally {
// Ensure all background processes are terminated
try {
await sdk.shutdownExternalMCPServers();
}
catch (shutdownError) {
logger.error("Error during SDK shutdown:", shutdownError);
}
if (!globalSession.getCurrentSessionId()) {
await CLICommandFactory.exitAfterStdoutDrain();
}
}
}
/**
* Handle stdin input for generate command
*/
static async handleGenerateStdinInput(argv) {
// M10: STT-only runs (--stt + --input-audio with no positional prompt)
// are valid ā the transcription becomes the prompt downstream. Skip the
// stdin/empty-input check in that case so users don't get
// "Input required..." for an STT-only command.
const isSttOnly = !!(argv.stt &&
argv.inputAudio);
if (!argv.input && !process.stdin.isTTY) {
// The SDK's MCP stdio transport unrefs stdin on import so an idle
// process can exit on its own (see externalServerManager.ts). This
// path actually reads stdin and must block on it, so ref it back
// before consuming.
ensureStdinRef();
let stdinData = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
stdinData += chunk;
}
const trimmedData = stdinData.trim();
if (!trimmedData) {
if (isSttOnly) {
return "";
}
throw new Error("ā No input received from stdin.\n" +
"š” Try this:\n" +
' neurolink generate "your prompt"\n' +
' echo "your prompt" | neurolink generate\n' +
' neurolink generate "Describe this image" --image ./photo.jpg');
}
return trimmedData;
}
else if (!argv.input) {
if (isSttOnly) {
return "";
}
throw new Error("ā Input required.\n" +
"š” Try this:\n" +
' neurolink generate "your prompt"\n' +
' echo "your prompt" | neurolink generate\n' +
' neurolink generate "Describe this image" --image ./photo.jpg');
}
return argv.input;
}
/**
* Detect output mode (video, ppt, avatar, music, or text) based on CLI arguments
*/
static detectGenerateOutputMode(argv, options) {
const outputMode = options.outputMode;
const hasPPTFlags = argv.pptPages !== undefined ||
argv.pptTheme !== undefined ||
argv.pptAudience !== undefined ||
argv.pptTone !== undefined ||
argv.pptOutput !== undefined ||
argv.pptAspectRatio !== undefined ||
argv.pptNoImages === true;
const hasVideoSignals = outputMode === "video" ||
argv.videoOutput !== undefined ||
argv.videoProvider !== undefined ||
argv.videoLength !== undefined ||
argv.videoResolution !== undefined ||
argv.videoAspectRatio !== undefined;
const hasPPTSignals = outputMode === "ppt" || hasPPTFlags;
const hasAvatarSignals = outputMode === "avatar" ||
argv.avatarProvider !== undefined ||
argv.avatarImage !== undefined ||
argv.avatarText !== undefined ||
argv.avatarAudio !== undefined ||
argv.avatarVoice !== undefined ||
argv.avatarOutput !== undefined;
const hasMusicSignals = outputMode === "music" ||
argv.musicProvider !== undefined ||
argv.musicGenre !== undefined ||
argv.musicMood !== undefined ||
argv.musicDuration !== undefined ||
argv.musicTempo !== undefined ||
argv.musicOutput !== undefined;
const activeModes = [
hasVideoSignals,
hasPPTSignals,
hasAvatarSignals,
hasMusicSignals,
].filter(Boolean).length;
if (activeModes > 1) {
throw new Error("Conflicting output mode signals detected. Use exactly one of video / ppt / avatar / music modes (or text).");
}
// Derive mode flags from the full signal set so that flag-only invocations
// (e.g. --videoOutput without --output-mode video) are handled correctly.
const isVideoMode = hasVideoSignals;
const isAvatarMode = hasAvatarSignals;
const isMusicMode = hasMusicSignals;
const isPPTMode = hasPPTSignals;
const spinnerMessage = isVideoMode
? "š¬ Generating video... (this may take 1-2 minutes)"
: isPPTMode
? "š Generating presentation... (this may take 2-5 minutes)"
: isAvatarMode
? "š¤ Generating avatar video... (this may take 1-3 minutes)"
: isMusicMode
? "šµ Generating music... (this may take 30s-2 minutes)"
: "š¤ Generating text...";
return {
isVideoMode,
isPPTMode,
isAvatarMode,
isMusicMode,
spinnerMessage,
};
}
/**
* Process context for generation command
*/
static processGenerateContext(inputText, options) {
let processedInputText = inputText;
let contextMetadata;
if (options.context && options.contextConfig) {
const processedContextResult = ContextFactory.processContext(options.context, options.contextConfig);
if (processedContextResult.processedContext) {
processedInputText =
processedContextResult.processedContext + processedInputText;
}
contextMetadata = {
...ContextFactory.extractAnalyticsContext(options.context),
contextMode: processedContextResult.config.mode,
contextTruncated: processedContextResult.metadata.truncated,
};
if (options.debug) {
logger.debug("Context processed:", {
mode: processedContextResult.config.mode,
truncated: processedContextResult.metadata.truncated,
processingTime: processedContextResult.metadata.processingTime,
});
}
}
return { inputText: processedInputText, contextMetadata };
}
/**
* Build multimodal input from CLI arguments
*/
static buildGenerateMultimodalInput(inputText, argv) {
const imageBuffers = CLICommandFactory.processCliImages(argv.image);
const csvFiles = CLICommandFactory.processCliCSVFiles(argv.csv);
const pdfFiles = CLICommandFactory.processCliPDFFiles(argv.pdf);
const videoFiles = CLICommandFactory.processCliVideoFiles(argv.video);
const files = CLICommandFactory.processCliFiles(argv.file);
return {
text: inputText,
...(imageBuffers && { images: imageBuffers }),
...(csvFiles && { csvFiles }),
...(pdfFiles && { pdfFiles }),
...(videoFiles && { videoFiles }),
...(files && { files }),
};
}
/**
* Build CSV processor options from CLI argv (#1199). Shared by executeGenerate
* and executeRealStream, which previously built byte-for-byte identical
* objects independently.
*/
static buildCsvOptionsFromArgv(argv) {
return {
maxRows: argv.csvMaxRows,
formatStyle: argv.csvFormat,
encoding: argv.csvEncoding,
sanitizeColumnNames: argv.csvSanitizeNames,
columnNameCase: argv.csvNameCase,
parseTimeoutMs: argv.csvParseTimeoutMs,
skipEmptyLines: argv.csvSkipEmptyLines,
};
}
/**
* Build video processor options from CLI argv. Shared by executeGenerate,
* executeStream and executeBatch, which previously built byte-for-byte
* identical `videoOptions` objects independently (mirrors
* {@link buildCsvOptionsFromArgv}).
*/
static buildVideoOptionsFromArgv(argv) {
return {
frames: argv.videoFrames,
quality: argv.videoQuality,
format: argv.videoFormat,
transcribeAudio: argv.transcribeAudio,
};
}
/**
* Build output configuration for generate request
*/
static buildGenerateOutputConfig(isVideoMode, isPPTMode, enhancedOptions, isAvatarMode = false, isMusicMode = false) {
if (isVideoMode) {
return {
mode: "video",
video: {
provider: enhancedOptions.videoProvider,
resolution: enhancedOptions.videoResolution,
length: enhancedOptions.videoLength,
aspectRatio: enhancedOptions.videoAspectRatio,
audio: enhancedOptions.videoAudio,
},
};
}
if (isAvatarMode) {
return {
mode: "avatar",
avatar: {
provider: enhancedOptions.avatarProvider,
image: enhancedOptions.avatarImage,
audio: enhancedOptions.avatarAudio,
text: enhancedOptions.avatarText,
voice: enhancedOptions.avatarVoice,
quality: enhancedOptions.avatarQuality,
format: enhancedOptions.avatarFormat,
output: enhancedOptions.avatarOutput,
},
};
}
if (isMusicMode) {
return {
mode: "music",
music: {
prompt: "", // Filled in from input.text/prompt by baseProvider
provider: enhancedOptions.musicProvider,
duration: enhancedOptions.musicDuration,
format: enhancedOptions.musicFormat,
genre: enhancedOptions.musicGenre,
mood: enhancedOptions.musicMood,
tempo: enhancedOptions.musicTempo,
output: enhancedOptions.musicOutput,
},
};
}
if (isPPTMode) {
return {
mode: "ppt",
ppt: {
pages: enhancedOptions.pptPages || 10,
theme: enhancedOptions.pptTheme,
audience: enhancedOptions.pptAudience,
tone: enhancedOptions.pptTone,
aspectRatio: enhancedOptions.pptAspectRatio || "16:9",
generateAIImages: !enhancedOptions.pptNoImages,
outputPath: enhancedOptions.pptOutput,
},
};
}
return undefined;
}
/**
* Handle successful generation result
*/
static async handleGenerateSuccess(result, options, modes, spinner) {
const { isVideoMode, isPPTMode, isAvatarMode, isMusicMode } = modes;
const genResult = result;
if (spinner) {
if (isVideoMode) {
spinner.succeed(chalk.green("ā
Video generated successfully!"));
}
else if (isPPTMode) {
spinner.succeed(chalk.green("ā
Presentation generated successfully!"));
}
else if (isAvatarMode) {
spinner.succeed(chalk.green("ā
Avatar video generated successfully!"));
}
else if (isMusicMode) {
spinner.succeed(chalk.green("ā
Music generated successfully!"));
}
else {
spinner.succeed(chalk.green("ā
Text generated successfully!"));
}
}
if (!options.quiet) {
const providerInfo = genResult.provider || "auto";
const modelInfo = genResult.model || "default";
logger.always(chalk.gray(`š§ Provider: ${providerInfo} | Model: ${modelInfo}`));
}
if (!isVideoMode && !isPPTMode && !isAvatarMode && !isMusicMode) {
CLICommandFactory.handleOutput(genResult, options);
}
await CLICommandFactory.handleTTSOutput(genResult, options);
await CLICommandFactory.handleVideoOutput(genResult, options);
await CLICommandFactory.handleAvatarOutput(genResult, options);
await CLICommandFactory.handleMusicOutput(genResult, options);
await CLICommandFactory.handlePPTOutput(genResult, options);
if (options.debug) {
logger.debug("\n" + chalk.yellow("Debug Information:"));
logger.debug("Provider:", genResult.provider);
logger.debug("Model:", genResult.model);
if (genResult.analytics) {
logger.debug("Analytics:", JSON.stringify(genResult.analytics, null, 2));
}
if (genResult.evaluation) {
logger.debug("Evaluation:", JSON.stringify(genResult.evaluation, null, 2));
}
}
if (!globalSession.getCurrentSessionId()) {
await CLICommandFactory.flushLangfuseTraces();
await CLICommandFactory.exitAfterStdoutDrain(0);
}
}
/**
* Execute the generate command
*/
static async executeGenerate(argv) {
// Handle stdin input
const rawInput = await CLICommandFactory.handleGenerateStdinInput(argv);
argv.input = rawInput;
const options = CLICommandFactory.processOptions(argv);
// Detect output mode
const { isVideoMode, isPPTMode, isAvatarMode, isMusicMode, spinnerMessage, } = CLICommandFactory.detectGenerateOutputMode(argv, options);
const spinner = argv.quiet ? null : ora(spinnerMessage).start();
try {
// Add delay if specified
if (options.delay) {
await new Promise((resolve) => setTimeout(resolve, options.delay));
}
validateCliInputFiles(argv);
validateCsvMaxRows(argv);
// Process context
const { inputText, contextMetadata } = CLICommandFactory.processGenerateContext(rawInput, options);
// Handle dry-run mode for testing
if (options.dryRun) {
const mockResult = {
content: "Mock response for testing purposes",
provider: options.provider || "auto",
model: options.model || "test-model",
usage: { input: 10, output: 15, total: 25 },
responseTime: 150,
analytics: options.enableAnalytics
? {
provider: options.provider || "auto",
model: options.model || "test-model",
tokenUsage: { input: 10, output: 15, total: 25 },
cost: 0.00025,
requestDuration: 150,
context: contextMetadata,
}
: undefined,
evaluation: options.enableEvaluation
? normalizeEvaluationData({
relevance: 8,
accuracy: 9,
completeness: 8,
overall: 8.3,
reasoning: "Test evaluation response",
evaluationModel: "test-evaluator",
evaluationTime: 50,
})
: undefined,
};
if (spinner) {
spinner.succeed(chalk.green("ā
Dry-run completed successfully!"));
}
CLICommandFactory.handleOutput(mockResult, options);
if (options.debug) {
logger.debug("\n" + chalk.yellow("Debug Information (Dry-run):"));
logger.debug("Provider:", mockResult.provider);
logger.debug("Model:", mockResult.model);
logger.debug("Mode: DRY-RUN (no actual API calls made)");
}
if (!globalSession.getCurrentSessionId()) {
await CLICommandFactory.flushLangfuseTraces();
await CLICommandFactory.exitAfterStdoutDrain(0);
}
return;
}
// Inject tool-routing config into the SDK instance before constructing it.
const toolRoutingConfig = buildToolRoutingConfigFromCli(options);
if (toolRoutingConfig) {
globalSession.setToolRoutingConfig(toolRoutingConfig);
}
// Inject classifier-router config into the SDK instance before constructing it.
const classifierRouterConfig = buildClassifierRouterConfigFromCli(options);
if (classifierRouterConfig) {
globalSession.setClassifierRouterConfig(classifierRouterConfig);
}
// Inject skills config (--skills-dir / NEUROLINK_SKILLS_DIR) before SDK construction.
const skillsConfig = buildSkillsConfigFromCli(options);
if (skillsConfig) {
globalSession.setSkillsConfig(skillsConfig);
}
globalSession.setFileToolRoots(argv.toolRoot);
// Initialize SDK and session
const sdk = globalSession.getOrCreateNeuroLink();
const sessionVariables = CLICommandFactory.normalizeLoopSessionVariables(globalSession.getSessionVariables());
const enhancedOptions = { ...options, ...sessionVariables };
const sessionId = globalSession.getCurrentSessionId();
const context = sessionId
? { ...options.context, sessionId }
: options.context;
if (options.debug) {
logger.debug("CLI Tools configuration:", {
disableTools: options.disableTools,
toolsEnabled: !options.disableTools,
});
}
// Configure mode-specific options
if (isVideoMode) {
CLICommandFactory.configureVideoMode(enhancedOptions, argv, options);
}
if (isPPTMode) {
CLICommandFactory.configurePPTMode(enhancedOptions, argv, options);
}
// Build multimodal input and output configuration
const generateInput = CLICommandFactory.buildGenerateMultimodalInput(inputText, argv);
const outputConfig = CLICommandFactory.buildGenerateOutputConfig(isVideoMode, isPPTMode, enhancedOptions, isAvatarMode, isMusicMode);
// Read audio file for STT if --input-audio is provided.
// NEW10: existsSync guard mirrors the stream handler so a missing file
// produces a friendly error here too instead of a raw ENOENT crash.
const inputAudioPath = enhancedOptions.inputAudio;
if (inputAudioPath && !fs.existsSync(inputAudioPath)) {
throw new Error(`--input-audio file not found: ${inputAudioPath}`);
}
const inputAudioBuffer = inputAudioPath
? fs.readFileSync(inputAudioPath)
: undefined;
// m2: shared format helper (was duplicated in generate + stream
// handlers; now lives in src/lib/utils/audioFormatDetector.ts).
const { inferAudioFormatFromPath } = await import("../../utils/audioFormatDetector.js");
const inputAudioFormat = inferAudioFormatFromPath(inputAudioPath);
const runGenerate = () => sdk.generate({
input: generateInput,
pdfOptions: {
password: CLICommandFactory.resolvePdfPassword(argv),
},
csvOptions: CLICommandFactory.buildCsvOptionsFromArgv(argv),
videoOptions: CLICommandFactory.buildVideoOptionsFromArgv(argv),
output: outputConfig,
provider: enhancedOptions.provider,
model: enhancedOptions.model,
temperature: enhancedOptions.temperature,
maxTokens: enhancedOptions.maxTokens,
topP: enhancedOptions.topP,
topK: enhancedOptions.topK,
stopSequences: enhancedOptions.stopSequences,
systemPrompt: enhancedOptions.systemPrompt,
agentMode: enhancedOptions.agentMode,
maxSteps: enhancedOptions.maxSteps,
timeout: enhancedOptions.timeout
? enhancedOptions.timeout * 1000
: undefined,
disableTools: enhancedOptions.disableTools,
enabledToolNames: enhancedOptions.enabledToolNames,
enableAnalytics: enhancedOptions.enableAnalytics,
enableEvaluation: enhancedOptions.enableEvaluation,
evaluationDomain: enhancedOptions.evaluationDomain,
toolUsageContext: enhancedOptions.toolUsageContext,
context: context,
region: options.region,
thinkingConfig: createThinkingConfigFromRecord(options),
factoryConfig: enhancedOptions.domain
? {
domainType: enhancedOptions.domain,
enhancementType: "domain-configuration",
validateDomainData: true,
}
: undefined,
// RAG configuration
rag: argv.ragFiles?.length
? {
files: argv.ragFiles,
strategy: argv.ragStrategy,
chunkSize: argv.ragChunkSize,
chunkOverlap: argv.ragChunkOverlap,
topK: argv.ragTopK,
}
: undefined,
// PII detection
piiDetection: argv.piiRedact
? {
enabled: true,
action: argv.piiAction ?? "redact",
detectTypes: argv.piiTypes
? argv.piiTypes
.split(",")
.map((t) => t.trim())
: undefined,
}
: undefined,
// Input validation
inputValidation: argv.inputMaxLength || argv.trimWhitespace || argv.requireContent
? {
maxLength: argv.inputMaxLength,
trimWhitespace: argv.trimWhitespace,
requireContent: argv.requireContent,
}
: undefined,
// Response validation
responseValidation: argv.outputMaxLength || argv.outputMinLength
? {
maxLength: argv.outputMaxLength,
minLength: argv.outputMinLength,
truncationAction: "truncate",
}
: undefined,
// TTS configuration
tts: enhancedOptions.tts
? {
enabled: true,
useAiResponse: true,
voice: enhancedOptions.ttsVoice,
provider: enhancedOptions.ttsProvider,
format: enhancedOptions.ttsFormat || undefined,
speed: enhancedOptions.ttsSpeed,
quality: enhancedOptions.ttsQuality,
output: enhancedOptions.ttsOutput,
play: enhancedOptions.ttsPlay,
}
: undefined,
// STT configuration
stt: enhancedOptions.stt
? {
enabled: true,
provider: enhancedOptions.sttProvider,
language: enhancedOptions.sttLanguage,
...(inputAudioBuffer && { audio: inputAudioBuffer }),
...(inputAudioFormat && { format: inputAudioFormat }),
}
: undefined,
});
const result = await runGenerate();
// Handle successful result
await CLICommandFactory.handleGenerateSuccess(result, options, { isVideoMode, isPPTMode, isAvatarMode, isMusicMode }, spinner);
}
catch (error) {
if (spinner) {
spinner.fail();
}
handleError(error, "Generation");
}
}
/**
* Process context for streaming
*/
static async processStreamContext(argv, options) {
let inputText = argv.input;
let contextMetadata;
if (options.context && options.contextConfig) {
const processedContextResult = ContextFactory.processContext(options.context, options.contextConfig);
// Integrate context into prompt if configured
if (processedContextResult.processedContext) {
inputText = processedContextResult.processedContext + inputText;
}
// Add context metadata for analytics
contextMetadata = {
...ContextFactory.extractAnalyticsContext(options.context),
contextMode: processedContextResult.config.mode,
contextTruncated: processedContextResult.metadata.truncated,
};
if (options.debug) {
logger.debug("Context processed for streaming:", {
mode: processedContextResult.config.mode,
truncated: processedContextResult.metadata.truncated,
processingTime: processedContextResult.metadata.processingTime,
});
}
}
return { inputText, contextMetadata };
}
/**
* Execute dry-run streaming simulation
*/
static async executeDryRunStream(options, contextMetadata) {
if (!options.quiet) {
logger.always(chalk.blue("š Dry-run streaming..."));
}
// Simulate streaming output
const chunks = [
"Mock ",
"streaming ",
"response ",
"for ",
"testing ",
"purposes",
];
let fullContent = "";
for (const chunk of chunks) {
await animatedWrite(chunk);
fullContent += chunk;
await new Promise((resolve) => setTimeout(resolve, 50)); // Simulate streaming delay
}
if (!options.quiet) {
process.stdout.write("\n");
}
// Mock analytics and evaluation for dry-run
if (options.enableAnalytics) {
const mockAnalytics = {
provider: options.provider || "auto",
model: options.model || "test-model",
requestDuration: 300,
tokenUsage: {
input: 10,
output: 15,
total: 25,
},
timestamp: new Date().toISOString(),
context: contextMetadata,
};
const mockGenerateResult = {
success: true,
content: fullContent,
analytics: mockAnalytics,
model: mockAnalytics.model,
toolsUsed: [],
};
const analyticsDisplay = CLICommandFactory.formatAnalyticsForTextMode(mockGenerateResult);
logger.always(analyticsDisplay);
}
if (options.enableEvaluation) {
logger.always(chalk.blue("\nš Response Evaluation (Dry-run):"));
logger.always(` Relevance: 8/10`);
logger.always(` Accuracy: 9/10`);
logger.always(` Completeness: 8/10`);
logger.always(` Overall: 8.3/10`);
logger.always(` Reasoning: Test evaluation response`);
}
if (options.output) {
fs.writeFileSync(options.output, fullContent);
if (!options.quiet) {
logger.always(`\nOutput saved to ${options.output}`);
}
}
if (options.debug) {
logger.debug("\n" + chalk.yellow("Debug Information (Dry-run Streaming):"));
logger.debug("Provider:", options.provider || "auto");
logger.debug("Model:", options.model || "test-model");
logger.debug("Mode: DRY-RUN (no actual API calls made)");
}
if (!globalSession.getCurrentSessionId()) {
await CLICommandFactory.flushLangfuseTraces();
await CLICommandFactory.exitAfterStdoutDrain(0);
}
}
/**
* Execute real streaming with timeout handling
*/
static async executeRealStream(argv, options, inputText, contextMetadata) {
// Inject tool-routing config into the SDK instance before constructing it.
const toolRoutingConfig = buildToolRoutingConfigFromCli(options);
if (toolRoutingConfig) {
globalSession.setToolRoutingConfig(toolRoutingConfig);
}
// Inject classifier-router config into the SDK instance before constructing it.
const classifierRouterConfig = buildClassifierRouterConfigFromCli(options);
if (classifierRouterConfig) {
globalSession.setClassifierRouterConfig(classifierRouterConfig);
}
// Inject skills config (--skills-dir / NEUROLINK_SKILLS_DIR) before SDK construction.
const skillsConfig = buildSkillsConfigFromCli(options);
if (skillsConfig) {
globalSession.setSkillsConfig(skillsConfig);
}
globalSession.setFileToolRoots(argv.toolRoot);
const sdk = globalSession.getOrCreateNeuroLink();
const sessionVariables = CLICommandFactory.normalizeLoopSessionVariables(globalSession.getSessionVariables());
const enhancedOptions = { ...options, ...sessionVariables };
const sessionId = globalSession.getCurrentSessionId();
const context = sessionId
? { ...contextMetadata, sessionId }
: contextMetadata;
// Process CLI multimodal inputs
const imageBuffers = CLICommandFactory.processCliImages(argv.image);
const csvFiles = CLICommandFactory.processCliCSVFiles(argv.csv);
const pdfFiles = CLICommandFactory.processCliPDFFiles(argv.pdf);
const videoFiles = CLICommandFactory.processCliVideoFiles(argv.video);
const files = CLICommandFactory.processCliFiles(argv.file);
const runStream = async () => sdk.stream({
input: {
text: inputText,
...(imageBuffers && { images: imageBuffers }),
...(csvFiles && { csvFiles }),
...(pdfFiles && { pdfFiles }),
...(videoFiles && { videoFiles }),
...(files && { files }),
},
pdfOptions: {
password: CLICommandFactory.resolvePdfPassword(argv),
},
csvOptions: CLICommandFactory.buildCsvOptionsFromArgv(argv),
videoOptions: CLICommandFactory.buildVideoOptionsFromArgv(argv),
provider: enhancedOptions.provider,
model: enhancedOptions.model,
temperature: enhancedOptions.temperature,
maxTokens: enhancedOptions.maxTokens,
topP: enhancedOptions.topP,
topK: enhancedOptions.topK,
stopSequences: enhancedOptions.stopSequences,
systemPrompt: enhancedOptions.systemPrompt,
agentMode: enhancedOptions.agentMode,
maxSteps: enhancedOptions.maxSteps,
timeout: enhancedOptions.timeout
? enhancedOptions.timeout * 1000
: undefined,
disableTools: enhancedOptions.disableTools,
enabledToolNames: enhancedOptions.enabledToolNames,
enableAnalytics: enhancedOptions.enableAnalytics,
enableEvaluation: enhancedOptions.enableEvaluation,
evaluationDomain: enhancedOptions.evaluationDomain,
toolUsageContext: enhancedOptions.toolUsageContext,
context: context,
region: options.region,
thinkingConfig: createThinkingConfigFromRecord(options),
factoryConfig: enhancedOptions.domain
? {
domainType: enhancedOptions.domain,
enhancementType: "domain-configuration",
validateDomainData: true,
}
: undefined,
// RAG configuration
rag: argv.ragFiles?.length
? {
files: argv.ragFiles,
strategy: argv.ragStrategy,
chunkSize: argv.ragChunkSize,
chunkOverlap: argv.ragChunkOverlap,
topK: argv.ragTopK,
}
: undefined,
// PII detection
piiDetection: argv.piiRedact
? {
enabled: true,
action: argv.piiAction ?? "redact",
detectTypes: argv.piiTypes
? argv.piiTypes
.split(",")
.map((t) => t.trim())
: undefined,
}
: undefined,
// Input validation
inputValidation: argv.inputMaxLength || argv.trimWhitespace || argv.requireContent
? {
maxLength: argv.inputMaxLength,
trimWhitespace: argv.trimWhitespace,
requireContent: argv.requireContent,
}
: undefined,
// Response validation
responseValidation: argv.outputMaxLength || argv.outputMinLength
? {
maxLength: argv.outputMaxLength,
minLength: argv.outputMinLength,
truncationAction: "truncate",
}
: undefined,
// TTS configuration
tts: enhancedOptions.tts
? {
enabled: true,
useAiResponse: true,
voice: enhancedOptions.ttsVoice,
provider: enhancedOptions.ttsProvider,
format: enhancedOptions.ttsFormat || undefined,
speed: enhancedOptions.ttsSpeed,
quality: enhancedOptions.ttsQuality,
output: enhancedOptions.ttsOutput,
play: enhancedOptions.ttsPlay,
}
: undefined,
// STT configuration. m2: shared format helper (was duplicated with
// the generate handler; now lives in audioFormatDetector.ts).
stt: enhancedOptions.stt
? await (async () => {
const streamSttAudioPath = enhancedOptions.inputAudio;
// Fail fast on a missing --input-audio so a CLI typo doesn't
// turn into a confusing provider/validation error later
// (matches the generate path).
if (streamSttAudioPath && !fs.existsSync(streamSttAudioPath)) {
throw new Error(`--input-audio file not found: ${streamSttAudioPath}`);
}
const streamSttAudio = streamSttAudioPath
? fs.readFileSync(streamSttAudioPath)
: undefined;
const { inferAudioFormatFromPath: inferFmt } = await import("../../utils/audioFormatDetector.js");
const streamSttFormat = inferFmt(streamSttAudioPath);
return {
enabled: true,
provider: enhancedOptions.sttProvider,
language: enhancedOptions.sttLanguage,
...(streamSttAudio && { audio: streamSttAudio }),
...(streamSttFormat && { format: streamSttFormat }),
};
})()
: undefined,
});
const stream = await runStream();
const streamResult = await CLICommandFactory.processStreamWithTimeout(stream, options);
await CLICommandFactory.displayStreamResults(stream, streamResult.content, options);
// Handle image output from stream (image models emit image events)
if (streamResult.imageBase64) {
try {
let imagePath;
if (options.imageOutput) {
imagePath = path.resolve(options.imageOutput);
const dir = path.dirname(imagePath);
if (dir && dir !== "." && !fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
else {
const imageDir = "generated-images";
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const ext = imageExtensionFor(undefined, streamResult.imageBase64);
imagePath = path.join(imageDir, `image-${timestamp}.${ext}`);
if (!fs.existsSync(imageDir)) {
fs.mkdirSync(imageDir, { recursive: true });
}
}
const imageBuffer = Buffer.from(streamResult.imageBase64, "base64");
fs.writeFileSync(imagePath, imageBuffer);
logger.always(`\nšø Generated image saved to: ${imagePath}`);
logger.always(` Image size: ${(imageBuffer.length / 1024).toFixed(2)} KB`);
}
catch (error) {
handleError(error, "Failed to save streamed image");
}
}
return streamResult.content;
}
/**
* Process stream with timeout handling
*/
static async processStreamWithTimeout(stream, options) {
let fullContent = "";
let lastImageBase64;
let contentReceived = false;
const abortController = new AbortController();
// BZ-667: Wire SIGINT to abort stream gracefully
const abortHandler = createStreamAbortHandler();
abortHandler.signal.addEventListener("abort", () => {
abortController.abort();
}, { once: true });
// Create timeout promise for stream consumption (default: 30 seconds, respects user-provided timeout)
const streamTimeout = options.timeout && typeof options.timeout === "number"
? options.timeout * 1000
: 30000;
const timeoutPromise = new Promise((_, reject) => {
const timeoutId = setTimeout(() => {
if (!contentReceived) {
const timeoutError = new Error(`\nā Stream timeout - no content received within ${streamTimeout / 1000} seconds\n` +
"This usually indicates authentication or network issues\n\n" +
"š§ Try these steps:\n" +
"1. Check your provider credentials are configured correctly\n" +
`2. Test generate mode: neurolink generate "test" --provider ${options.provider}\n` +
`3. Use debug mode: neurolink stream "test" --provider ${options.provider} --debug`);
reject(timeoutError);
}
}, streamTimeout);
// Clean up timeout when aborted
abortController.signal.addEventListener("abort", () => {
clearTimeout(timeoutId);
});
});
const streamIterator = stream.stream[Symbol.asyncIterator]();
try {
// Process the stream with timeout handling
let timeoutActive = true;
// BZ-667: Create an abort promise that rejects when the user presses Ctrl+C,
// so we can race it against streamIterator.next() and unblock pending reads.
const abortPromise = new Promise((_, reject) => {
if (abortHandler.signal.aborted) {
reject(new DOMException("Stream aborted", "AbortError"));
return;
}
abortHandler.signal.addEventListener("abort", () => {
reject(new DOMException("Stream aborted", "AbortError"));
}, { once: true });
});
while (true) {
let nextResult;
if (timeoutActive && !contentReceived) {
// Race between next chunk, timeout, and abort signal
nextResult = await Promise.race([
streamIterator.next(),
timeoutPromise,
abortPromise,
]);
}
else {
// Race between next chunk and abort signal
nextResult = await Promise.race([
streamIterator.next(),
abortPromise,
]);
}
if (nextResult.done) {
break;
}
if (!contentReceived) {
contentReceived = true;
timeoutActive = false;
abortController.abort(); // Cancel timeout
}
if (options.delay && options.delay > 0) {
// Demo mode - add delay between chunks
await new Promise((resolve) => setTimeout(resolve, options.delay));
}
const evt = nextResult.value;
const isText = (o) => !!o &&
typeof o === "object" &&
typeof o.content === "string";
const isAudio = (o) => {
if (!o || typeof o !== "object") {
return false;
}
const t = o.type;
return t === "audio" || t === "tts_audio";
};
const isImage = (o) => {
if (!o || typeof o !== "object") {
return false;
}
const record = o;
if (record.type !== "image") {
return false;
}
if (!record.imageOutput || typeof record.imageOutput !== "object") {
return false;
}
return (typeof record.imageOutput.base64 ===
"string");
};
if (isText(evt)) {
await animatedWrite(evt.content);
fullContent += evt.content;
}
else if (isAudio(evt)) {
if (options.debug && !options.quiet) {
process.stdout.write("[audio-chunk]");
}
}
else if (isImage(evt)) {
lastImageBase64 = evt.imageOutput.base64;
if (options.debug && !options.quiet) {
process.stdout.write("[image-received]");
}
}
}
}
catch (error) {
abortController.abort(); // Clean up timeout
// BZ-667: Close the stream iterator so the provider connection is released.
// Wrap in try/catch to prevent cleanup failures from masking the original error.
try {
await streamIterator.return?.();
}
catch {
// Iterator cleanup failed ā swallow so the original error propagates
}
abortHandler.cleanup();
// BZ-667: Handle graceful abort ā return partial content instead of throwing
if (abortHandler.signal.aborted ||
(error instanceof Error && error.name === "AbortError")) {
if (!options.quiet) {
process.stdout.write("\n");
}
return { content: fullContent, imageBase64: lastImageBase64 };
}
throw error;
}
abortHandler.cleanup();
if (!contentReceived) {
throw new Error("\nā No content received from stream\n" +
"Check your credentials and provider configuration");
}
if (!options.quiet) {
process.stdout.write("\n");
}
return { content: fullContent, imageBase64: lastImageBase64 };
}
/**
* Display analytics and evaluation results
*/
static async displayStreamResults(stream, fullContent, options) {
// Display analytics after streaming
if (options.enableAnalytics && stream.analytics) {
const resolvedAnalytics = await (stream.analytics instanceof Promise
? stream.analytics
: Promise.resolve(stream.analytics));
const streamAnalytics = {
success: true,
content: fullContent,
analytics: resolvedAnalytics,
model: stream.model,
toolsUsed: stream.toolCalls?.map((tc) => tc.toolName) || [],
};
const analyticsDisplay = CLICommandFactory.formatAnalyticsForTextMode(streamAnalytics);
logger.always(analyticsDisplay);
}
// Display evaluation after streaming
if (options.enableEvaluation && stream.evaluation) {
const resolvedEvaluation = await (stream.evaluation instanceof Promise
? stream.evaluation
: Promise.resolve(stream.evaluation));
logger.always(chalk.blue("\nš Response Evaluation:"));
logger.always(` Relevance: ${resolvedEvaluation.relevance}/10`);
logger.always(` Accuracy: ${resolvedEvaluation.accuracy}/10`);
logger.always(` Completeness: ${resolvedEvaluation.completeness}/10`);
logger.always(` Overall: ${resolvedEvaluation.overall}/10`);
if (resolvedEvaluation.reasoning) {
logger.always(` Reasoning: ${resolvedEvaluation.reasoning}`);
}
}
}
/**
* Handle stream output file writing and debug output
*/
static async handleStreamOutput(options, fullContent) {
// Handle output file if specified
if (options.output) {
fs.writeFileSync(options.output, fullContent);
if (!options.quiet) {
logger.always(`\nOutput saved to ${options.output}`);
}
}
// Handle TTS audio output/playback if --tts-output or --tts-play is provided
// Note: For streaming, TTS audio is collected during the stream
// and saved at the end if available
const ttsOutputPath = options.ttsOutput;
const shouldPlay = options.ttsPlay;
if (ttsOutputPath || shouldPlay) {
// For now, streaming TTS output is not yet available
// This will be enabled when the TTS streaming infrastructure is complete
if (!options.quiet) {
logger.always(chalk.yellow("TTS audio for streaming is not yet available. Use 'generate' command for TTS output."));
}
}
// Debug output for streaming
if (options.debug) {
await CLICommandFactory.logStreamDebugInfo({
provider: options.provider,
model: options.model,
});
}
}
/**
* Log debug information for stream result
*/
static async logStreamDebugInfo(stream) {
logger.debug("\n" + chalk.yellow("Debug Information (Streaming):"));
logger.debug("Provider:", stream.provider);
logger.debug("Model:", stream.model);
if (stream.analytics) {
const resolvedAnalytics = await (stream.analytics instanceof Promise
? stream.analytics
: Promise.resolve(stream.analytics));
logger.debug("Analytics:", JSON.stringify(resolvedAnalytics, null, 2));
}
if (stream.evaluation) {
const resolvedEvaluation = await (stream.evaluation instanceof Promise
? stream.evaluation
: Promise.resolve(stream.evaluation));
logger.debug("Evaluation:", JSON.stringify(resolvedEvaluation, null, 2));
}
if (stream.metadata) {
logger.debug("Metadata:", JSON.stringify(stream.metadata, null, 2));
}
}
/**
* Handle stdin input for stream command
*/
static async handleStdinInput(argv) {
// STT-only flow: --stt --input-audio <file> with no text prompt is now
// valid (the stream pipeline transcribes the audio and uses the result
// as the prompt). Skip the stdin/empty-input rejection in that case.
const isSttOnly = !!argv.stt && !!argv.inputAudio;
if (!argv.input && !process.stdin.isTTY) {
// The SDK's MCP stdio transport unrefs stdin on import so an idle
// process can exit on its own (see externalServerManager.ts). This
// path actually reads stdin and must block on it, so ref it back
// before consuming.
ensureStdinRef();
let stdinData = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
stdinData += chunk;
}
argv.input = stdinData.trim();
if (!argv.input) {
if (isSttOnly) {
argv.input = "";
return;
}
throw new Error("ā No input received from stdin.\n" +
"š” Try this:\n" +
' neurolink stream "your prompt"\n' +
' echo "your prompt" | neurolink stream\n' +
' neurolink stream "Describe this image" --image ./photo.jpg');
}
}
else if (!argv.input) {
if (isSttOnly) {
argv.input = "";
return;
}
throw new Error("ā Input required.\n" +
"š” Try this:\n" +
' neurolink stream "your prompt"\n' +
' echo "your prompt" | neurolink stream\n' +
' neurolink stream "Describe this image" --image ./photo.jpg');
}
}
/**
* Execute the stream command
*/
static async executeStream(argv) {
await CLICommandFactory.handleStdinInput(argv);
const options = CLICommandFactory.processOptions(argv);
// Validate Anthropic subscription options if using Anthropic provider
CLICommandFactory.validateAnthropicSubscriptionOptions(options);
if (!options.quiet) {
logger.always(chalk.blue("š Streaming..."));
}
try {
// Add delay if specified
if (options.delay) {
await new Promise((resolve) => setTimeout(resolve, options.delay));
}
validateCliInputFiles(argv);
validateCsvMaxRows(argv);
const { inputText, contextMetadata } = await CLICommandFactory.processStreamContext(argv, options);
// Handle dry-run mode for testing
if (options.dryRun) {
await CLICommandFactory.executeDryRunStream(options, contextMetadata);
return;
}
const fullContent = await CLICommandFactory.executeRealStream(argv, options, inputText, contextMetadata);
await CLICommandFactory.handleStreamOutput(options, fullContent);
if (!globalSession.getCurrentSessionId()) {
await CLICommandFactory.flushLangfuseTraces();
await CLICommandFactory.exitAfterStdoutDrain(0);
}
}
catch (error) {
handleError(error, "Streaming");
}
}
/**
* Execute the batch command
*/
static async executeBatch(argv) {
const options = CLICommandFactory.processOptions(argv);
// Validate Anthropic subscription options if using Anthropic provider
CLICommandFactory.validateAnthropicSubscriptionOptions(options);
const spinner = options.quiet ? null : ora().start();
try {
if (!argv.promptsFile) {
throw new Error("No file specified");
}
// #291/round-4 #1191: validate multimodal flags and resolve their
// paths *before* the prompts file is read/parsed below, mirroring
// generate/stream (which validate all inputs before any processing).
// Previously this ran after `fs.readFileSync(resolvedPromptsPath)`,
// so a large prompts file paired with an invalid --image path paid
// the read/parse cost before the flag error ever surfaced. `--file`
// (the common auto-detect flag) isn't registered on batch at all ā
// see createBatchCommand ā so `argv.file` can never be set here; no
// exclusion needed to protect the (differently-named) positional.
validateCliInputFiles(argv);
validateCsvMaxRows(argv);
const batchImages = CLICommandFactory.processCliImages(argv.image);
const batchCsvFiles = CLICommandFactory.processCliCSVFiles(argv.csv);
const batchPdfFiles = CLICommandFactory.processCliPDFFiles(argv.pdf);
const batchVideoFiles = CLICommandFactory.processCliVideoFiles(argv.video);
// #291: route the prompts-list positional through the same friendly
// aggregated-error path as the multimodal flags above (not found /
// unreadable / directory / malformed file:// URL), instead of a raw
// existsSync+readFileSync that throws an unfriendly EISDIR when
// pointed at a directory.
const resolvedPromptsPath = validatePromptsFilePath(argv.promptsFile);
const buffer = fs.readFileSync(resolvedPromptsPath);
const prompts = buffer
.toString("utf8")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (prompts.length === 0) {
throw new Error("No prompts found in file");
}
if (spinner) {
spinner.text = `š¦ Processing ${prompts.length} prompts...`;
}
else if (!options.quiet) {
logger.always(chalk.blue(`š¦ Processing ${prompts.length} prompts...\n`));
}
const results = [];
// Inject tool-routing config into the SDK instance before constructing it.
const toolRoutingConfig = buildToolRoutingConfigFromCli(options);
if (toolRoutingConfig) {
globalSession.setToolRoutingConfig(toolRoutingConfig);
}
// Inject classifier-router config into the SDK instance before constructing it.
const classifierRouterConfig = buildClassifierRouterConfigFromCli(options);
if (classifierRouterConfig) {
globalSession.setClassifierRouterConfig(classifierRouterConfig);
}
// Inject skills config (--skills-dir / NEUROLINK_SKILLS_DIR) before SDK construction.
const skillsConfig = buildSkillsConfigFromCli(options);
if (skillsConfig) {
globalSession.setSkillsConfig(skillsConfig);
}
globalSession.setFileToolRoots(argv.toolRoot);
const sdk = globalSession.getOrCreateNeuroLink();
const sessionVariables = CLICommandFactory.normalizeLoopSessionVariables(globalSession.getSessionVariables());
const enhancedOptions = { ...options, ...sessionVariables };
const sessionId = globalSession.getCurrentSessionId();
// batchImages/batchCsvFiles/batchPdfFiles/batchVideoFiles were already
// validated and resolved above, before the prompts file was read.
if (batchImages?.length ||
batchCsvFiles?.length ||
batchPdfFiles?.length ||
batchVideoFiles?.length) {
// Pause the spinner so the notice isn't swallowed by its re-renders.
// Safety-relevant, so always visible (not gated behind --quiet) and
// written to stderr so `--format json` output on stdout stays parseable.
spinner?.stop();
logger.alwaysStderr(chalk.yellow("ā ļø Multimodal files (--image/--csv/--pdf/--video) are attached to ALL prompts in this batch."));
spinner?.start();
}
// Resolve the PDF password once for the whole batch. resolvePdfPassword
// emits a "visible in shell history" stderr warning when the flag is set;
// it must fire once per run (like generate/stream), not once per prompt.
const batchPdfPassword = batchPdfFiles?.length
? CLICommandFactory.resolvePdfPassword(argv)
: undefined;
for (let i = 0; i < prompts.length; i++) {
if (spinner) {
spinner.text = `Processing ${i + 1}/${prompts.length}: ${prompts[i].substring(0, 30)}...`;
}
try {
// Handle dry-run mode for batch processing
if (options.dryRun) {
results.push({
prompt: prompts[i],
response: `Mock batch response ${i + 1} for testing purposes`,
});
if (spinner) {
spinner.render();
}
continue;
}
// Process context for each batch item
let inputText = prompts[i];
let contextMetadata;
if (options.context && options.contextConfig) {
const processedContextResult = ContextFactory.processContext(options.context, options.contextConfig);
if (processedContextResult.processedContext) {
inputText = processedContextResult.processedContext + inputText;
}
contextMetadata = {
...ContextFactory.extractAnalyticsContext(options.context),
contextMode: processedContextResult.config.mode,
contextTruncated: processedContextResult.metadata.truncated,
batchIndex: i,
};
}
const context = sessionId
? { ...contextMetadata, sessionId }
: contextMetadata;
const runBatchGenerate = () => sdk.generate({
input: {
text: inputText,
...(batchImages?.length && { images: batchImages }),
...(batchCsvFiles?.length && { csvFiles: batchCsvFiles }),
...(batchPdfFiles?.length && { pdfFiles: batchPdfFiles }),
...(batchVideoFiles?.length && {
videoFiles: batchVideoFiles,
}),
},
// Only construct these when the corresponding files are
// actually attached ā batch has no CSV/video mode to opt into,
// unlike generate/stream, so an empty options object here is
// pure noise on every prompt of every batch run.
...(batchCsvFiles?.length && {
csvOptions: CLICommandFactory.buildCsvOptionsFromArgv(argv),
}),
...(batchPdfFiles?.length && {
pdfOptions: {
password: batchPdfPassword,
},
}),
...(batchVideoFiles?.length && {
videoOptions: CLICommandFactory.buildVideoOptionsFromArgv(argv),
}),
provider: enhancedOptions.provider,
model: enhancedOptions.model,
temperature: enhancedOptions.temperature,
maxTokens: enhancedOptions.maxTokens,
topP: enhancedOptions.topP,
topK: enhancedOptions.topK,
stopSequences: enhancedOptions.stopSequences,
systemPrompt: enhancedOptions.systemPrompt,
timeout: enhancedOptions.timeout
? enhancedOptions.timeout * 1000
: undefined,
disableTools: enhancedOptions.disableTools,
enabledToolNames: enhancedOptions.enabledToolNames,
evaluationDomain: enhancedOptions.evaluationDomain,
toolUsageContext: enhancedOptions.toolUsageContext,
context: context,
factoryConfig: enhancedOptions.domain
? {
domainType: enhancedOptions.domain,
enhancementType: "domain-configuration",
validateDomainData: true,
}
: undefined,
});
const result = await runBatchGenerate();
results.push({ prompt: prompts[i], response: result.content });
if (spinner) {
spinner.render();
}
}
catch (error) {
results.push({
prompt: prompts[i],
error: error.message,
});
if (spinner) {
spinner.render();
}
}
// Add delay between requests
if (i < prompts.length - 1) {
await new Promise((resolve) => setTimeout(resolve, options.delay || 1000));
}
}
if (spinner) {
spinner.succeed(chalk.green("ā
Batch processing complete!"));
}
// Handle output with universal formatting
CLICommandFactory.handleOutput(results, options);
if (!globalSession.getCurrentSessionId()) {
await CLICommandFactory.flushLangfuseTraces();
await CLICommandFactory.exitAfterStdoutDrain(0);
}
}
catch (error) {
if (spinner) {
spinner.fail();
}
handleError(error, "Batch processing");
}
}
/**
* Execute config export command
*/
static async executeConfigExport(argv) {
const options = CLICommandFactory.processOptions(argv);
try {
const config = {
providers: {
openai: !!process.env.OPENAI_API_KEY,
bedrock: !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY),
vertex: !!(process.env.GOOGLE_APPLICATION_CREDENTIALS ||
process.env.GOOGLE_SERVICE_ACCOUNT_KEY),
anthropic: !!process.env.ANTHROPIC_API_KEY,
azure: !!(process.env.AZURE_OPENAI_API_KEY &&
process.env.AZURE_OPENAI_ENDPOINT),
"google-ai": !!process.env.GOOGLE_AI_API_KEY,
},
defaults: {
temperature: 0.7,
maxTokens: 500,
},
timestamp: new Date().toISOString(),
};
CLICommandFactory.handleOutput(config, options);
}
catch (error) {
handleError(error, "Configuration export");
}
}
/**
* Execute get best provider command
*/
static async executeGetBestProvider(argv) {
const options = CLICommandFactory.processOptions(argv);
try {
const { getBestProvider } = await import("../../utils/providerUtils.js");
const bestProvider = await getBestProvider();
if (options.format === "json") {
CLICommandFactory.handleOutput({ provider: bestProvider }, options);
}
else {
if (!options.quiet) {
logger.always(chalk.green(`šÆ Best available provider: ${bestProvider}`));
}
else {
CLICommandFactory.handleOutput(bestProvider, options);
}
}
}
catch (error) {
handleError(error, "Provider selection");
}
}
/**
* Execute memory stats command
*/
/**
* Resolve a standalone SkillsManager for the `skills` command group.
* Directory precedence: --skills-dir > NEUROLINK_SKILLS_DIR > ./skills.
* Cache is disabled so every command sees the directory's current state.
*/
static resolveSkillsManagerForCli(argv) {
const raw = argv.skillsDir;
const dir = (typeof raw === "string" && raw.trim()) ||
process.env.NEUROLINK_SKILLS_DIR?.trim() ||
"./skills";
return new SkillsManager({
enabled: true,
storage: { type: "filesystem", path: dir },
indexCacheTtlMs: 0,
});
}
static async executeSkillsList(argv) {
const options = CLICommandFactory.processOptions(argv);
try {
const manager = CLICommandFactory.resolveSkillsManagerForCli(argv);
const skills = await manager.list();
if (options.format === "json") {
CLICommandFactory.handleOutput({ skills, count: skills.length }, options);
return;
}
if (skills.length === 0) {
logger.always(chalk.yellow("No active skills found."));
return;
}
logger.always(chalk.blue(`š§© Skills (${skills.length}):`));
for (const skill of skills) {
const tags = skill.tags?.length ? ` [${skill.tags.join(", ")}]` : "";
logger.always(` ${chalk.bold(skill.name)} ā ${skill.description}${tags}`);
}
}
catch (error) {
handleError(error, "Skills list");
}
}
static async executeSkillsShow(argv) {
const options = CLICommandFactory.processOptions(argv);
try {
const manager = CLICommandFactory.resolveSkillsManagerForCli(argv);
const skill = await manager.get(argv.skill);
if (!skill) {
logger.always(chalk.yellow(`Skill "${argv.skill}" not found.`));
process.exitCode = 1;
return;
}
if (options.format === "json") {
CLICommandFactory.handleOutput(skill, options);
return;
}
logger.always(chalk.blue(`š§© ${skill.displayName || skill.name}`));
logger.always(` id: ${skill.id} version: ${skill.version ?? 1}`);
logger.always(` ${skill.description}`);
if (skill.tags?.length) {
logger.always(` tags: ${skill.tags.join(", ")}`);
}
logger.always("");
logger.always(skill.instructions);
}
catch (error) {
handleError(error, "Skills show");
}
}
static async executeSkillsSearch(argv) {
const options = CLICommandFactory.processOptions(argv);
try {
const manager = CLICommandFactory.resolveSkillsManagerForCli(argv);
const matches = await manager.search({
query: argv.query,
...(argv.tag ? { tag: argv.tag } : {}),
});
if (options.format === "json") {
CLICommandFactory.handleOutput({ skills: matches, count: matches.length }, options);
return;
}
if (matches.length === 0) {
logger.always(chalk.yellow(`No skills match "${argv.query}".`));
return;
}
logger.always(chalk.blue(`š ${matches.length} match(es):`));
for (const skill of matches) {
logger.always(` ${chalk.bold(skill.name)} ā ${skill.description}`);
}
logger.always("");
logger.always("Use `neurolink skills show <name>` for full instructions.");
}
catch (error) {
handleError(error, "Skills search");
}
}
static async executeSkillsCreate(argv) {
const options = CLICommandFactory.processOptions(argv);
try {
let instructions = argv.instructions;
if (!instructions && argv.instructionsFile) {
instructions = fs.readFileSync(argv.instructionsFile, "utf-8");
}
if (!instructions?.trim()) {
logger.always(chalk.red("Provide skill instructions via --instructions or --instructions-file."));
process.exitCode = 1;
return;
}
const manager = CLICommandFactory.resolveSkillsManagerForCli(argv);
const result = await manager.requestMutation({
type: "create",
skill: {
name: argv.name,
description: argv.description,
instructions,
...(argv.displayName ? { displayName: argv.displayName } : {}),
...(argv.tags?.length ? { tags: argv.tags.map(String) } : {}),
},
});
if (options.format === "json") {
CLICommandFactory.handleOutput(result, options);
return;
}
logger.always(chalk.green(`ā
Skill "${argv.name}" created (id: ${result.skill?.id}).`));
}
catch (error) {
handleError(error, "Skills create");
}
}
static async executeSkillsDelete(argv) {
const options = CLICommandFactory.processOptions(argv);
try {
const manager = CLICommandFactory.resolveSkillsManagerForCli(argv);
const existing = await manager.get(argv.skill);
if (!existing) {
logger.always(chalk.yellow(`Skill "${argv.skill}" not found.`));
process.exitCode = 1;
return;
}
const result = await manager.requestMutation({
type: "delete",
skillId: existing.id,
});
if (options.format === "json") {
CLICommandFactory.handleOutput(result, options);
return;
}
logger.always(chalk.green(`ā
Skill "${existing.name}" deprecated (soft-deleted).`));
}
catch (error) {
handleError(error, "Skills delete");
}
}
static async executeMemoryStats(argv) {
const options = CLICommandFactory.processOptions(argv);
const spinner = options.quiet
? null
: ora("š§ Getting memory stats...").start();
try {
const sdk = globalSession.getOrCreateNeuroLink();
// Handle dry-run mode
if (options.dryRun) {
const mockStats = {
totalSessions: 5,
totalTurns: 47,
memoryUsage: "Active",
};
if (spinner) {
spinner.succeed(chalk.green("ā
Memory stats retrieved (dry-run)"));
}
CLICommandFactory.handleOutput(mockStats, options);
return;
}
const stats = await sdk.getConversationStats();
if (spinner) {
spinner.succeed(chalk.green("ā
Memory stats retrieved"));
}
if (options.format === "json") {
CLICommandFactory.handleOutput(stats, options);
}
else {
logger.always(chalk.blue("š Conversation Memory Stats:"));
logger.always(` Total Sessions: ${stats.totalSessions}`);
logger.always(` Total Turns: ${stats.totalTurns}`);
logger.always(` Memory Status: ${stats.totalSessions > 0 ? "Active" : "Empty"}`);
}
}
catch (error) {
if (spinner) {
spinner.fail("Memory stats failed");
}
if (error.message.includes("not enabled")) {
logger.always(chalk.yellow("ā ļø Conversation memory is not enabled"));
logger.always("Enable it by using --enable-conversation-memory with loop mode");
}
else {
handleError(error, "Memory stats");
}
}
}
/**
* Execute memory history command
*/
static async executeMemoryHistory(argv) {
const options = CLICommandFactory.processOptions(argv);
const spinner = options.quiet
? null
: ora(`š§ Getting history for ${argv.sessionId}...`).start();
try {
const sdk = globalSession.getOrCreateNeuroLink();
// Handle dry-run mode
if (options.dryRun) {
const mockHistory = [
{ role: "user", content: "Hello, how are you?" },
{
role: "assistant",
content: "I'm doing well, thank you! How can I help you today?",
},
{ role: "user", content: "Can you explain quantum computing?" },
{
role: "assistant",
content: "Quantum computing is a revolutionary technology...",
},
];
if (spinner) {
spinner.succeed(chalk.green(`ā
History retrieved for ${argv.sessionId} (dry-run)`));
}
CLICommandFactory.handleOutput(mockHistory, options);
return;
}
const history = await sdk.getConversationHistory(argv.sessionId);
if (spinner) {
spinner.succeed(chalk.green(`ā
History retrieved for ${argv.sessionId}`));
}
if (history.length === 0) {
logger.always(chalk.yellow(`ā ļø No conversation history found for session: ${argv.sessionId}`));
return;
}
if (options.format === "json") {
CLICommandFactory.handleOutput(history, options);
}
else {
logger.always(chalk.blue(`š¬ Conversation History (${argv.sessionId}):`));
for (const message of history) {
const roleColor = message.role === "user" ? chalk.cyan : chalk.green;
const roleLabel = message.role === "user" ? "User" : "Assistant";
logger.always(` [${roleColor(roleLabel)}]: ${message.content}`);
}
}
}
catch (error) {
if (spinner) {
spinner.fail("Memory history failed");
}
if (error.message.includes("not enabled")) {
logger.always(chalk.yellow("ā ļø Conversation memory is not enabled"));
logger.always("Enable it by using --enable-conversation-memory with loop mode");
}
else {
handleError(error, "Memory history");
}
}
}
/**
* Execute memory clear command
*/
static async executeMemoryClear(argv) {
const options = CLICommandFactory.processOptions(argv);
const isAllSessions = !argv.sessionId;
// Require --confirm flag when clearing all sessions
if (isAllSessions && !argv.confirm) {
logger.always(chalk.red("ā ļø Clearing all sessions requires --confirm flag"));
logger.always(chalk.yellow("Usage: neurolink memory clear --confirm"));
logger.always(chalk.gray("This safeguard prevents accidental data loss."));
return;
}
const target = isAllSessions ? "all sessions" : `session ${argv.sessionId}`;
const spinner = options.quiet
? null
: ora(`š§ Clearing ${target}...`).start();
try {
const sdk = globalSession.getOrCreateNeuroLink();
// Handle dry-run mode
if (options.dryRun) {
if (spinner) {
spinner.succeed(chalk.green(`ā
${isAllSessions ? "All sessions" : "Session"} cleared (dry-run)`));
}
const result = {
success: true,
action: isAllSessions ? "clear_all" : "clear_session",
sessionId: argv.sessionId || null,
message: `${isAllSessions ? "All sessions" : "Session"} would be cleared`,
};
CLICommandFactory.handleOutput(result, options);
return;
}
let success;
if (isAllSessions) {
await sdk.clearAllConversations();
success = true;
}
else {
// sessionId is guaranteed to exist when isAllSessions is false
if (!argv.sessionId) {
throw new Error("Session ID is required for clearing specific session");
}
success = await sdk.clearConversationSession(argv.sessionId);
}
if (spinner) {
if (success) {
spinner.succeed(chalk.green(`ā
${isAllSessions ? "All sessions" : "Session"} cleared successfully`));
}
else {
spinner.warn(chalk.yellow(`ā ļø Session ${argv.sessionId} not found or already empty`));
}
}
if (options.format === "json") {
const result = {
success,
action: isAllSessions ? "clear_all" : "clear_session",
sessionId: argv.sessionId || null,
};
CLICommandFactory.handleOutput(result, options);
}
else if (!success && !isAllSessions) {
logger.always(chalk.yellow(`ā ļø Session ${argv.sessionId} not found or already empty`));
}
else if (!options.quiet) {
logger.always(chalk.green(`ā
${isAllSessions ? "All conversation history" : `Session ${argv.sessionId}`} cleared`));
}
}
catch (error) {
if (spinner) {
spinner.fail("Memory clear failed");
}
if (error.message.includes("not enabled")) {
logger.always(chalk.yellow("ā ļø Conversation memory is not enabled"));
logger.always("Enable it by using --enable-conversation-memory with loop mode");
}
else {
handleError(error, "Memory clear");
}
}
}
/**
* Execute memory list command
*/
static async executeMemoryList(argv) {
const options = CLICommandFactory.processOptions(argv);
const spinner = options.quiet
? null
: ora("š Listing conversation sessions...").start();
try {
const sdk = globalSession.getOrCreateNeuroLink();
// Handle dry-run mode
if (options.dryRun) {
const mockSessions = [
{
id: "session-123",
title: "Machine Learning Discussion",
messageCount: 15,
lastActive: "2 hours ago",
createdAt: "2024-01-01T10:00:00Z",
updatedAt: "2024-01-01T12:30:00Z",
},
{
id: "session-456",
title: "API Design Review",
messageCount: 8,
lastActive: "1 day ago",
createdAt: "2024-01-02T14:00:00Z",
updatedAt: "2024-01-02T15:15:00Z",
},
];
if (spinner) {
spinner.succeed(chalk.green("ā
Sessions listed (dry-run)"));
}
CLICommandFactory.handleOutput(mockSessions, options);
return;
}
const sessions = await sdk.listSessions(argv.userId);
if (spinner) {
spinner.succeed(chalk.green(`ā
Found ${sessions.length} session(s)`));
}
if (sessions.length === 0) {
logger.always(chalk.yellow("ā ļø No conversation sessions found"));
return;
}
if (options.format === "json") {
CLICommandFactory.handleOutput(sessions, options);
}
else {
// Table format output
logger.always(chalk.blue(`š Conversation Sessions (${sessions.length} total):`));
logger.always("");
logger.always(chalk.gray("Session ID".padEnd(40) +
" | " +
"Messages".padEnd(10) +
" | " +
"Last Active"));
logger.always(chalk.gray("-".repeat(75)));
for (const session of sessions) {
const idDisplay = session.id.length > 38
? session.id.substring(0, 35) + "..."
: session.id.padEnd(40);
const msgCount = String(session.messageCount).padEnd(10);
const lastActive = session.lastActive || "Unknown";
logger.always(`${idDisplay} | ${msgCount} | ${lastActive}`);
}
}
}
catch (error) {
if (spinner) {
spinner.fail("Session listing failed");
}
if (error.message.includes("not enabled")) {
logger.always(chalk.yellow("ā ļø Conversation memory is not enabled"));
logger.always("Enable it by using --enable-conversation-memory with loop mode");
}
else {
handleError(error, "Memory list");
}
}
}
/**
* Resolve the requested export format for memory export commands.
* The generic `--format` flag is typed for the generate/stream commands
* (text/json/table/yaml), so read the raw value here and narrow it to the
* two serializations the memory export path actually supports.
*/
static resolveMemoryExportFormat(argv) {
const raw = typeof argv.format === "string" ? argv.format.toLowerCase() : "";
return raw === "csv" ? "csv" : "json";
}
/**
* Build a filesystem-safe filename for a session export.
*
* A session ID is caller-controlled data (it can come from Redis keys, user
* input, etc.), so it must never be interpolated directly into a path ā a
* value like `../../etc/cron.d/x` would otherwise let export-all write
* outside the chosen output directory. We reduce the ID to a bare basename
* and allow-list its characters; callers additionally verify containment.
*/
static sanitizeSessionExportFilename(sessionId, extension) {
// Strip any directory components, then drop everything that isn't a safe
// filename character and any leading dots (which could form `..`).
const base = path.basename(String(sessionId ?? ""));
const sanitized = base.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+/, "");
const safe = sanitized.length > 0 ? sanitized : "session";
return `${safe}.${extension}`;
}
/**
* Serialize one or more session exports to CSV. Each message becomes a row;
* session-level fields are repeated per row so the output is a flat,
* spreadsheet-friendly table.
*/
static sessionExportsToCsv(exports) {
const escape = (value) => {
const s = value === undefined || value === null ? "" : String(value);
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const header = [
"sessionId",
"title",
"messageId",
"role",
"content",
"timestamp",
"tool",
];
const rows = [header.join(",")];
for (const exp of exports) {
for (const msg of exp.messages) {
rows.push([
escape(exp.sessionId),
escape(exp.title),
escape(msg.id),
escape(msg.role),
escape(msg.content),
escape(msg.timestamp ?? msg.metadata?.timestamp),
escape(msg.tool),
].join(","));
}
}
return rows.join("\r\n") + "\r\n";
}
/**
* Execute memory export command
*/
static async executeMemoryExport(argv) {
const options = CLICommandFactory.processOptions(argv);
const exportFormat = CLICommandFactory.resolveMemoryExportFormat(argv);
const spinner = options.quiet
? null
: ora(`š¤ Exporting session ${argv.sessionId}...`).start();
try {
const sdk = globalSession.getOrCreateNeuroLink();
// Handle dry-run mode
if (options.dryRun) {
const mockExport = {
sessionId: argv.sessionId,
title: "Sample Conversation",
createdAt: "2024-01-01T10:00:00Z",
updatedAt: "2024-01-01T12:30:00Z",
messages: [
{ id: "msg-1", role: "user", content: "Hello!" },
{ id: "msg-2", role: "assistant", content: "Hi there!" },
],
exportMetadata: argv.includeMetadata
? {
exportedAt: new Date().toISOString(),
exportFormat,
}
: undefined,
};
if (spinner) {
spinner.succeed(chalk.green(`ā
Session exported (dry-run)`));
}
if (exportFormat === "csv") {
logger.always(CLICommandFactory.sessionExportsToCsv([mockExport]));
}
else {
CLICommandFactory.handleOutput(mockExport, {
...options,
format: "json",
});
}
return;
}
const exportData = await sdk.exportSession(argv.sessionId, {
includeMetadata: argv.includeMetadata,
format: exportFormat,
});
if (!exportData) {
if (spinner) {
spinner.warn(chalk.yellow(`ā ļø Session ${argv.sessionId} not found`));
}
return;
}
if (spinner) {
spinner.succeed(chalk.green(`ā
Session exported (${exportData.messages.length} messages)`));
}
// Emit in the requested format (suitable for piping to a file).
if (exportFormat === "csv") {
logger.always(CLICommandFactory.sessionExportsToCsv([exportData]));
}
else {
CLICommandFactory.handleOutput(exportData, {
...options,
format: "json",
});
}
}
catch (error) {
if (spinner) {
spinner.fail("Session export failed");
}
if (error.message.includes("not enabled")) {
logger.always(chalk.yellow("ā ļø Conversation memory is not enabled"));
logger.always("Enable it by using --enable-conversation-memory with loop mode");
}
else {
handleError(error, "Memory export");
}
}
}
/**
* Execute memory export-all command
*/
static async executeMemoryExportAll(argv) {
const options = CLICommandFactory.processOptions(argv);
const exportFormat = CLICommandFactory.resolveMemoryExportFormat(argv);
const spinner = options.quiet
? null
: ora("š¤ Exporting all sessions...").start();
try {
const sdk = globalSession.getOrCreateNeuroLink();
// Handle dry-run mode
if (options.dryRun) {
if (spinner) {
spinner.succeed(chalk.green(`ā
Sessions would be exported to ${argv.output} (dry-run)`));
}
const result = {
success: true,
outputDirectory: argv.output,
sessionCount: 2,
message: "Dry-run: sessions would be exported",
};
CLICommandFactory.handleOutput(result, options);
return;
}
// Ensure output directory exists
if (!fs.existsSync(argv.output)) {
fs.mkdirSync(argv.output, { recursive: true });
}
const exports = await sdk.exportAllSessions(argv.userId, {
includeMetadata: argv.includeMetadata,
format: exportFormat,
});
if (exports.length === 0) {
if (spinner) {
spinner.warn(chalk.yellow("ā ļø No sessions found to export"));
}
return;
}
// Write each session to a file. The session ID is untrusted input, so it
// is sanitized to a bare filename and the resolved path is verified to
// stay inside the output directory before writing (path-traversal guard).
const resolvedOutput = path.resolve(argv.output);
let writtenCount = 0;
for (const sessionExport of exports) {
const filename = CLICommandFactory.sanitizeSessionExportFilename(sessionExport.sessionId, exportFormat);
const filepath = path.resolve(resolvedOutput, filename);
// Defense-in-depth: refuse to write anything that escaped the dir.
if (path.dirname(filepath) !== resolvedOutput) {
logger.always(chalk.yellow(`ā ļø Skipping session with unsafe id: ${sessionExport.sessionId}`));
continue;
}
const contents = exportFormat === "csv"
? CLICommandFactory.sessionExportsToCsv([sessionExport])
: JSON.stringify(sessionExport, null, 2);
fs.writeFileSync(filepath, contents);
writtenCount++;
}
if (spinner) {
spinner.succeed(chalk.green(`ā
Exported ${writtenCount} session(s) to ${argv.output}`));
}
if (options.format === "json") {
const result = {
success: true,
outputDirectory: argv.output,
format: exportFormat,
sessionCount: writtenCount,
sessions: exports.map((e) => ({
sessionId: e.sessionId,
messageCount: e.messages.length,
})),
};
CLICommandFactory.handleOutput(result, options);
}
else {
logger.always(chalk.blue(`š Exported sessions to: ${argv.output}`));
for (const sessionExport of exports) {
logger.always(` ⢠${sessionExport.sessionId} (${sessionExport.messages.length} messages)`);
}
}
}
catch (error) {
if (spinner) {
spinner.fail("Export all failed");
}
if (error.message.includes("not enabled")) {
logger.always(chalk.yellow("ā ļø Conversation memory is not enabled"));
logger.always("Enable it by using --enable-conversation-memory with loop mode");
}
else {
handleError(error, "Memory export-all");
}
}
}
/**
* Execute memory delete command
*/
static async executeMemoryDelete(argv) {
const options = CLICommandFactory.processOptions(argv);
// Require confirmation unless --force is explicitly provided
if (!argv.force) {
// In quiet mode, we can't prompt interactively, so require --force
if (options.quiet) {
logger.always(chalk.red("ā ļø Deleting a session in quiet mode requires --force flag"));
logger.always(chalk.yellow(`Usage: neurolink memory delete ${argv.sessionId} --force`));
return;
}
// The SDK's MCP stdio transport unrefs stdin on import so an idle
// process can exit on its own (see externalServerManager.ts). This
// prompt actually reads stdin and must block on it, so ref it back
// before consuming.
ensureStdinRef();
const readline = await import("readline");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise((resolve) => {
rl.question(chalk.yellow(`ā ļø Are you sure you want to delete session "${argv.sessionId}"? (y/N): `), resolve);
});
rl.close();
if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
logger.always(chalk.gray("Deletion cancelled."));
return;
}
}
const spinner = options.quiet
? null
: ora(`šļø Deleting session ${argv.sessionId}...`).start();
try {
const sdk = globalSession.getOrCreateNeuroLink();
// Handle dry-run mode
if (options.dryRun) {
if (spinner) {
spinner.succeed(chalk.green(`ā
Session ${argv.sessionId} deleted (dry-run)`));
}
const result = {
success: true,
action: "delete",
sessionId: argv.sessionId,
message: "Session would be deleted",
};
CLICommandFactory.handleOutput(result, options);
return;
}
const success = await sdk.clearConversationSession(argv.sessionId);
if (spinner) {
if (success) {
spinner.succeed(chalk.green(`ā
Session ${argv.sessionId} deleted successfully`));
}
else {
spinner.warn(chalk.yellow(`ā ļø Session ${argv.sessionId} not found or already deleted`));
}
}
if (options.format === "json") {
const result = {
success,
action: "delete",
sessionId: argv.sessionId,
};
CLICommandFactory.handleOutput(result, options);
}
}
catch (error) {
if (spinner) {
spinner.fail("Session deletion failed");
}
if (error.message.includes("not enabled")) {
logger.always(chalk.yellow("ā ļø Conversation memory is not enabled"));
logger.always("Enable it by using --enable-conversation-memory with loop mode");
}
else {
handleError(error, "Memory delete");
}
}
}
/**
* Execute completion command
*/
static async executeCompletion(argv) {
try {
// Generate shell completion script as concatenated strings to avoid template literal issues
const completionScript = "#!/usr/bin/env bash\n\n" +
"# NeuroLink CLI Bash Completion Script\n" +
"# Generated by: neurolink completion\n" +
"# \n" +
"# Installation instructions:\n" +
"# 1. Save this script to a file (e.g., ~/.neurolink-completion.sh)\n" +
"# 2. Add to your shell profile: source ~/.neurolink-completion.sh\n" +
"# 3. Restart your shell or run: source ~/.bashrc\n\n" +
"_neurolink_completion() {\n" +
" local cur prev opts base\n" +
" COMPREPLY=()\n" +
' cur="${COMP_WORDS[COMP_CWORD]}"\n' +
' prev="${COMP_WORDS[COMP_CWORD - 1]}"\n\n' +
" # Main commands\n" +
" if [[ ${COMP_CWORD} -eq 1 ]]; then\n" +
' opts="generate gen stream batch provider status models mcp discover memory config get-best-provider completion"\n' +
' COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )\n' +
" return 0\n" +
" fi\n\n" +
" # Subcommand completion\n" +
' case "${COMP_WORDS[1]}" in\n' +
" generate|gen)\n" +
' case "${prev}" in\n' +
" --provider|-p)\n" +
' COMPREPLY=( $(compgen -W "' +
BASH_COMPLETION_PROVIDERS +
'" -- ${cur}) )\n' +
" return 0\n" +
" ;;\n" +
" --format|-f|--output-format)\n" +
' COMPREPLY=( $(compgen -W "text json table" -- ${cur}) )\n' +
" return 0\n" +
" ;;\n" +
" --model|-m)\n" +
' COMPREPLY=( $(compgen -W "gemini-2.5-pro gemini-2.5-flash gpt-4o gpt-4o-mini claude-3-5-sonnet" -- ${cur}) )\n' +
" return 0\n" +
" ;;\n" +
" *)\n" +
' opts="--provider --model --temperature --maxTokens --system --format --output --timeout --delay --disableTools --enableAnalytics --enableEvaluation --debug --quiet --noColor --configFile --dryRun"\n' +
' COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )\n' +
" return 0\n" +
" ;;\n" +
" esac\n" +
" ;;\n" +
" mcp)\n" +
' case "${COMP_WORDS[2]}" in\n' +
" install)\n" +
" if [[ ${COMP_CWORD} -eq 3 ]]; then\n" +
' COMPREPLY=( $(compgen -W "filesystem github postgres sqlite brave puppeteer git memory bitbucket" -- ${cur}) )\n' +
" return 0\n" +
" fi\n" +
" ;;\n" +
" *)\n" +
" if [[ ${COMP_CWORD} -eq 2 ]]; then\n" +
' opts="list install add test exec remove"\n' +
' COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )\n' +
" return 0\n" +
" fi\n" +
" ;;\n" +
" esac\n" +
" ;;\n" +
" provider)\n" +
" if [[ ${COMP_CWORD} -eq 2 ]]; then\n" +
' COMPREPLY=( $(compgen -W "status" -- ${cur}) )\n' +
" return 0\n" +
" fi\n" +
" ;;\n" +
" models)\n" +
" if [[ ${COMP_CWORD} -eq 2 ]]; then\n" +
' COMPREPLY=( $(compgen -W "list test" -- ${cur}) )\n' +
" return 0\n" +
" fi\n" +
" ;;\n" +
" config)\n" +
" if [[ ${COMP_CWORD} -eq 2 ]]; then\n" +
' COMPREPLY=( $(compgen -W "init show validate reset export" -- ${cur}) )\n' +
" return 0\n" +
" fi\n" +
" ;;\n" +
" memory)\n" +
" if [[ ${COMP_CWORD} -eq 2 ]]; then\n" +
' COMPREPLY=( $(compgen -W "stats history clear" -- ${cur}) )\n' +
" return 0\n" +
" fi\n" +
" ;;\n" +
" *)\n" +
" # Global options for all commands\n" +
' opts="--help --version --debug --quiet --noColor --configFile"\n' +
' COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )\n' +
" return 0\n" +
" ;;\n" +
" esac\n\n" +
" # File completion for certain options\n" +
' case "${prev}" in\n' +
" --output|-o|--configFile)\n" +
" COMPREPLY=( $(compgen -f -- ${cur}) )\n" +
" return 0\n" +
" ;;\n" +
" batch)\n" +
" COMPREPLY=( $(compgen -f -- ${cur}) )\n" +
" return 0\n" +
" ;;\n" +
" esac\n\n" +
" return 0\n" +
"}\n\n" +
"# Register the completion function\n" +
"complete -F _neurolink_completion neurolink\n\n" +
"# Zsh completion (if running zsh)\n" +
'if [[ -n "${ZSH_VERSION}" ]]; then\n' +
" autoload -U +X bashcompinit && bashcompinit\n" +
" complete -F _neurolink_completion neurolink\n" +
"fi\n\n" +
'echo "NeuroLink CLI completion script loaded successfully!"\n' +
'echo "Available commands: generate, stream, batch, provider, status, models, mcp, discover, config, get-best-provider, completion"';
// Handle output options
if (argv.output) {
const fs = await import("fs");
fs.writeFileSync(argv.output, completionScript);
if (!argv.quiet) {
logger.always(`ā
Completion script saved to ${argv.output}`);
logger.always(`š” Run: source ${argv.output}`);
}
}
else {
logger.always(completionScript);
}
if (!argv.quiet) {
logger.always(chalk.blue("\nš Installation Instructions:"));
logger.always("1. Save the output to a file:");
logger.always(" neurolink completion > ~/.neurolink-completion.sh");
logger.always("2. Add to your shell profile:");
logger.always(" echo 'source ~/.neurolink-completion.sh' >> ~/.bashrc");
logger.always("3. Restart your shell or run:");
logger.always(" source ~/.bashrc");
logger.always(chalk.green("\nš Then enjoy tab completion for NeuroLink commands!"));
}
}
catch (error) {
handleError(error, "Completion generation");
}
}
/**
* Flush Langfuse traces before exit
*/
static async flushLangfuseTraces() {
try {
logger.debug("[CLI] Flushing Langfuse traces before exit...");
const { flushOpenTelemetry } = await import("../../services/server/ai/observability/instrumentation.js");
await flushOpenTelemetry();
logger.debug("[CLI] Langfuse traces flushed successfully");
}
catch (error) {
logger.error("[CLI] Error flushing Langfuse traces", { error });
}
}
/** process.exit discards piped stdout queued past the 64 KiB buffer; resolves false if the reader stalls past the bound. */
static flushStdout() {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), resolveStdoutDrainTimeoutMs());
timer.unref();
process.stdout.write("", () => {
clearTimeout(timer);
resolve(true);
});
});
}
/** Forced exit that fails loudly when queued output could not be delivered. */
static async exitAfterStdoutDrain(exitCode) {
if (await CLICommandFactory.flushStdout()) {
process.exit(exitCode);
}
process.stderr.write(STDOUT_DRAIN_WARNING);
process.exit(STDOUT_DRAIN_FAILED_EXIT_CODE);
}
}
/** Re-export of CLICommandFactory's static option definitions for direct import by tests/tooling. */
export const commonOptions = CLICommandFactory.commonOptions;
/**
* File extension for generated image bytes. The provider now labels what it
* returned, so a WebP no longer gets written to a `.png` filename ā which is
* the same wrong-label problem one layer up from the provider.
*/
function imageExtensionFor(mimeType, base64) {
const known = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
};
if (mimeType && known[mimeType]) {
return known[mimeType];
}
// No label from the provider: sniff the leading bytes rather than assume.
const head = Buffer.from(base64.slice(0, 64), "base64");
const sniffed = sniffImageMimeType(head);
return sniffed ? known[sniffed] : "png";
}
//# sourceMappingURL=commandFactory.js.map