From e78ffc1a4080d1e4d8fbf602cbe975cd4e93987b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arwid=20Thornstr=C3=B6m?= Date: Wed, 11 Feb 2026 09:39:22 +0100 Subject: [PATCH] Add AI routing and schema export for pwcli (#32) * Enhance help documentation by adding 'schema' subcommand details and examples for AI commands * updated models for translate * updated gpt models * Add command discovery and routing functionality to CLI * added schema --- docs/help.md | 6 +- src/cli.ts | 472 ++++++++++++++++++ src/commands/ai/schema/schema.ts | 323 ++++++++++++ .../ai/translate_excel/translate_excel.ts | 18 +- 4 files changed, 817 insertions(+), 2 deletions(-) create mode 100644 src/commands/ai/schema/schema.ts diff --git a/docs/help.md b/docs/help.md index 1eea320..90ff7f4 100644 --- a/docs/help.md +++ b/docs/help.md @@ -7,6 +7,7 @@ ``` pwcli ├── ai (ai) +│ ├── schema (sc) .................................... ai sc │ └── promptcontextlibrary (pcl) ..................... ai pcl │ ├── aws (a) @@ -73,9 +74,12 @@ Commands can be executed using their aliases. For example: AI-related commands #### Subcommands: +- **schema** (alias: `sc`) - Output detailed pwcli command schema for LLM usage - **promptcontextlibrary** (alias: `pcl`) - Copy context files to clipboard for AI prompts -**Example:** `pwcli ai pcl` +**Examples:** +- `pwcli ai sc` +- `pwcli ai pcl` --- diff --git a/src/cli.ts b/src/cli.ts index 69e75e0..62a23b8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,447 @@ const { build } = require('gluegun'); const fs = require('fs'); const path = require('path'); +const os = require('os'); +const { GoogleGenerativeAI } = require('@google/generative-ai'); +const OpenAI = require('openai'); + +type CommandModule = { + name?: string; + alias?: string[]; + description?: string; + run?: (...args: any[]) => Promise | void; +}; + +type CommandEntry = { + commandPath: string[]; + aliases: string[]; + description: string; +}; + +type CommandNode = { + children: Record; + command?: CommandEntry; +}; + +const GEMINI_ROUTER_MODEL = 'gemini-flash-latest'; +const OPENAI_ROUTER_MODEL = 'gpt-4o-mini'; + +const isNonEmptyString = (value: unknown): value is string => { + return typeof value === 'string' && value.trim().length > 0; +}; + +const hasCommandInput = (argv: string[]): boolean => { + const args = argv.slice(2); + return args.some((arg) => !arg.startsWith('-') && arg !== '--dev'); +}; + +const extractCommandTokens = (args: string[]): string[] => { + const tokens: string[] = []; + for (const arg of args) { + if (arg === '--dev') { + continue; + } + if (arg.startsWith('-')) { + break; + } + tokens.push(arg); + } + return tokens; +}; + +const findCommandsRoot = (): string => { + const candidates = [ + path.resolve(__dirname, 'commands'), + path.resolve(__dirname, '../src/commands'), + path.resolve(process.cwd(), 'src/commands'), + path.resolve(process.cwd(), 'build/commands'), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + return candidate; + } + } + + throw new Error('Unable to locate commands directory.'); +}; + +const collectCommandFiles = (dir: string): string[] => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectCommandFiles(fullPath)); + continue; + } + + if ( + entry.isFile() && + (entry.name.endsWith('.ts') || entry.name.endsWith('.js')) && + !entry.name.endsWith('.d.ts') + ) { + files.push(fullPath); + } + } + + return files; +}; + +const getCommandPathFromFile = ( + commandsRoot: string, + commandFilePath: string, +): string[] => { + const relativeFilePath = path.relative(commandsRoot, commandFilePath); + const parts = relativeFilePath.split(path.sep); + const fileName = parts[parts.length - 1].replace(/\.(ts|js)$/, ''); + const directories = parts.slice(0, -1); + + if (directories[directories.length - 1] === fileName) { + return directories; + } + + return [...directories, fileName]; +}; + +const discoverCommands = (): CommandEntry[] => { + const commandsRoot = findCommandsRoot(); + const commandFiles = collectCommandFiles(commandsRoot); + const commands: CommandEntry[] = []; + + for (const commandFile of commandFiles) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const requiredModule = require(commandFile); + const commandModule: CommandModule = + requiredModule && requiredModule.default + ? requiredModule.default + : requiredModule; + + if ( + !commandModule || + typeof commandModule !== 'object' || + typeof commandModule.run !== 'function' || + typeof commandModule.name !== 'string' + ) { + continue; + } + + const aliases = Array.isArray(commandModule.alias) + ? commandModule.alias.filter((alias) => typeof alias === 'string') + : []; + + commands.push({ + commandPath: getCommandPathFromFile(commandsRoot, commandFile), + aliases, + description: commandModule.description || '', + }); + } + + return commands; +}; + +const createCommandTree = (commands: CommandEntry[]): CommandNode => { + const root: CommandNode = { children: {} }; + const commandLookup = new Map(); + + for (const command of commands) { + commandLookup.set(command.commandPath.join(' '), command); + } + + for (const command of commands) { + let node = root; + for (let i = 0; i < command.commandPath.length; i++) { + const route = command.commandPath.slice(0, i + 1).join(' '); + const routeEntry = commandLookup.get(route); + const segment = command.commandPath[i]; + const keys = new Set([segment]); + + if (routeEntry?.aliases?.length) { + for (const alias of routeEntry.aliases) { + keys.add(alias); + } + } + + let nextNode: CommandNode | null = null; + for (const key of keys) { + if (node.children[key]) { + nextNode = node.children[key]; + break; + } + } + + if (!nextNode) { + nextNode = { children: {} }; + } + + for (const key of keys) { + node.children[key] = nextNode; + } + + node = nextNode; + } + + node.command = command; + } + + return root; +}; + +const isKnownCommandInvocation = ( + commandTokens: string[], + commandTree: CommandNode, +): boolean => { + if (commandTokens.length === 0) { + return true; + } + + let node = commandTree; + for (const token of commandTokens) { + const next = node.children[token]; + if (!next) { + return false; + } + node = next; + } + + return Boolean(node.command); +}; + +const readPwcliSettings = (): any | null => { + const settingsPath = path.join(os.homedir(), '.pwcli_settings'); + try { + if (!fs.existsSync(settingsPath)) { + return null; + } + return JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + } catch { + return null; + } +}; + +const safeJsonParse = (value: string): any | null => { + try { + return JSON.parse(value); + } catch { + return null; + } +}; + +const extractJsonFromText = (text: string): string => { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced && fenced[1]) { + return fenced[1].trim(); + } + return text.trim(); +}; + +const readHelpDocumentation = (): string => { + const candidates = [ + path.join(__dirname, '../docs/help.md'), + path.join(__dirname, '../../docs/help.md'), + path.resolve(process.cwd(), 'docs/help.md'), + ]; + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + return fs.readFileSync(candidate, 'utf8'); + } + } catch { + continue; + } + } + + return ''; +}; + +const createRouterPrompt = ( + input: string, + commands: CommandEntry[], +): string => { + const commandCatalog = commands + .map((command) => { + const canonical = `pwcli ${command.commandPath.join(' ')}`.trim(); + const aliasPath = [ + 'pwcli', + ...command.commandPath.map((_, index) => { + const route = command.commandPath.slice(0, index + 1).join(' '); + const routeCommand = commands.find( + (c) => c.commandPath.join(' ') === route, + ); + if (routeCommand?.aliases?.[0]) { + return routeCommand.aliases[0]; + } + return command.commandPath[index]; + }), + ].join(' '); + return { + canonical, + alias: aliasPath, + description: command.description || '', + }; + }) + .sort((a, b) => a.canonical.localeCompare(b.canonical)); + + const helpDocs = readHelpDocumentation(); + const helpSection = helpDocs + ? `\nDetailed CLI documentation (includes flags, parameters, and examples):\n${helpDocs}\n` + : ''; + + return ` +You are an intent-to-command router for the pwcli CLI. +You must map user text to ONE existing pwcli command invocation only. +You are not allowed to execute anything, explain anything, or suggest shell commands. + +Rules: +- Output JSON only. +- JSON schema: +{ + "argv": ["token1", "token2", "..."], + "confidence": 0.0 +} +- "argv" must be tokens that come after "pwcli". Example: ["d","ts","s"] +- The command must match one of the catalog entries by canonical path or aliases. +- Prefer alias form when compact and clear (e.g. d ts s). +- Use CLI flags for known parameters when user provides them (example: branch -> --branch, server/testserver -> --stack). +- Include all relevant flags the user mentions or implies. Refer to the detailed documentation below for available flags per command. +- Do not include text outside JSON. +- Never output shell syntax or separators (;, &&, |, $, \`, >, <). + +If you are unsure, choose the safest read-only command that best matches intent (for status-like questions prefer testserver status). + +User input: +${input} + +Available command catalog: +${JSON.stringify(commandCatalog, null, 2)} +${helpSection}`.trim(); +}; + +const requestGeminiRouting = async ( + prompt: string, + apiKey: string, +): Promise => { + try { + const genAI = new GoogleGenerativeAI(apiKey); + const model = genAI.getGenerativeModel({ + model: GEMINI_ROUTER_MODEL, + generationConfig: { + temperature: 0.1, + topP: 0.9, + topK: 32, + maxOutputTokens: 512, + }, + }); + const result = await model.generateContent(prompt); + return result.response.text(); + } catch { + return null; + } +}; + +const requestOpenAiRouting = async ( + prompt: string, + apiKey: string, +): Promise => { + try { + const openai = new OpenAI({ apiKey }); + const response = await openai.chat.completions.create({ + model: OPENAI_ROUTER_MODEL, + temperature: 0.1, + messages: [{ role: 'user', content: prompt }], + }); + return response.choices[0]?.message?.content || null; + } catch { + return null; + } +}; + +const sanitizeAiArgv = (argv: unknown): string[] | null => { + if (!Array.isArray(argv) || argv.length === 0) { + return null; + } + + const cleaned: string[] = []; + for (const token of argv) { + if (!isNonEmptyString(token)) { + return null; + } + const trimmed = token.trim(); + if ( + trimmed.includes(';') || + trimmed.includes('&&') || + trimmed.includes('|') || + trimmed.includes('`') || + trimmed.includes('$(') || + trimmed.includes('>') || + trimmed.includes('<') + ) { + return null; + } + cleaned.push(trimmed); + } + + return cleaned; +}; + +const validateAiCommand = ( + aiArgv: string[], + commandTree: CommandNode, +): boolean => { + const commandTokens = extractCommandTokens(aiArgv); + return isKnownCommandInvocation(commandTokens, commandTree); +}; + +const resolveUnknownCommandWithAi = async ( + userArgs: string[], + commands: CommandEntry[], + commandTree: CommandNode, +): Promise => { + const settings = readPwcliSettings(); + const geminiApiKey = settings?.llm_api_keys?.gemini_api_key; + const openAiApiKey = settings?.llm_api_keys?.openai_api_key; + + if (!isNonEmptyString(geminiApiKey) && !isNonEmptyString(openAiApiKey)) { + return null; + } + + const input = userArgs.join(' ').trim(); + if (!input) { + return null; + } + + const prompt = createRouterPrompt(input, commands); + let responseText: string | null = null; + + // Gemini has priority when both keys exist. + if (isNonEmptyString(geminiApiKey)) { + responseText = await requestGeminiRouting(prompt, geminiApiKey); + } else if (isNonEmptyString(openAiApiKey)) { + responseText = await requestOpenAiRouting(prompt, openAiApiKey); + } + + if (!responseText) { + return null; + } + + const parsed = safeJsonParse(extractJsonFromText(responseText)); + if (!parsed) { + return null; + } + + const aiArgv = sanitizeAiArgv(parsed.argv); + if (!aiArgv) { + return null; + } + + if (!validateAiCommand(aiArgv, commandTree)) { + return null; + } + + return aiArgv; +}; /** * Create the cli and kick it off @@ -51,6 +492,37 @@ async function run(argv) { }) // provides default for help, h, --help, -h // .version() // provides default for version, v, --version, -v .create(); + + try { + const commands = discoverCommands(); + const commandTree = createCommandTree(commands); + const userArgs = argv.slice(2); + const commandTokens = extractCommandTokens(userArgs); + const commandLooksKnown = isKnownCommandInvocation( + commandTokens, + commandTree, + ); + + if (hasCommandInput(argv) && !commandLooksKnown) { + const aiArgv = await resolveUnknownCommandWithAi( + userArgs, + commands, + commandTree, + ); + if (aiArgv && aiArgv.length > 0) { + console.log(`[ai-router] Running: pwcli ${aiArgv.join(' ')}`); + // Execute the resolved command directly. + // Pass only the command tokens — gluegun only strips the + // node/script prefix when the array === process.argv, so + // constructing a new array with those prefixes would cause + // the command to be unrecognised. + return await cli.run(aiArgv); + } + } + } catch { + // Intentionally ignore AI router failures and let normal CLI resolution run. + } + // enable the following method if you'd like to skip loading one of these core extensions // this can improve performance if they're not necessary for your project: // .exclude(['meta', 'strings', 'print', 'filesystem', 'semver', 'system', 'prompt', 'http', 'template', 'patching', 'package-manager']) diff --git a/src/commands/ai/schema/schema.ts b/src/commands/ai/schema/schema.ts new file mode 100644 index 0000000..a5bd2ef --- /dev/null +++ b/src/commands/ai/schema/schema.ts @@ -0,0 +1,323 @@ +import { GluegunMenuToolbox } from '@lenne.tech/gluegun-menu'; +const fs = require('fs'); +const path = require('path'); + +type CommandModule = { + name?: string; + alias?: string[]; + description?: string; + hidden?: boolean; + run?: (...args: any[]) => Promise | void; +}; + +type CommandSchema = { + name: string; + commandPath: string[]; + aliases: string[]; + description: string; + hidden: boolean; + sourceFile: string; + detectedFlags: string[]; + interactive: boolean; + supportsJsonOutput: boolean; + riskLevel: 'low' | 'medium' | 'high'; + llmUsage: { + intent: string; + invocation: string; + aliasInvocation: string; + whenToUse: string; + preconditions: string[]; + expectedOutput: string; + }; +}; + +const PROMPT_CONSTRUCTORS = ['select', 'autocomplete', 'input', 'confirm']; + +const findCommandsRoot = (): string => { + const candidates = [ + path.resolve(__dirname, '../..'), + path.resolve(process.cwd(), 'src/commands'), + path.resolve(process.cwd(), 'build/commands'), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + return candidate; + } + } + + throw new Error('Unable to locate commands directory.'); +}; + +const collectCommandFiles = (dir: string): string[] => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectCommandFiles(fullPath)); + continue; + } + + if ( + entry.isFile() && + (entry.name.endsWith('.ts') || entry.name.endsWith('.js')) && + !entry.name.endsWith('.d.ts') + ) { + files.push(fullPath); + } + } + + return files; +}; + +const getCommandPathFromFile = ( + commandsRoot: string, + commandFilePath: string, +): string[] => { + const relativeFilePath = path.relative(commandsRoot, commandFilePath); + const parts = relativeFilePath.split(path.sep); + const fileName = parts[parts.length - 1].replace(/\.(ts|js)$/, ''); + const directories = parts.slice(0, -1); + + if (directories[directories.length - 1] === fileName) { + return directories; + } + + return [...directories, fileName]; +}; + +const readSourceFile = (commandFilePath: string): string => { + try { + return fs.readFileSync(commandFilePath, 'utf8'); + } catch { + return ''; + } +}; + +const detectFlags = (source: string): string[] => { + const flags = new Set(); + const optionsRegex = + /toolbox\.parameters\.options(?:\[['"]([a-z0-9-]+)['"]\]|\.([a-zA-Z0-9_]+))/g; + + let match: RegExpExecArray | null = optionsRegex.exec(source); + while (match !== null) { + const option = (match[1] || match[2] || '') + .replace(/_/g, '-') + .toLowerCase(); + if (option) { + flags.add(`--${option}`); + } + match = optionsRegex.exec(source); + } + + return [...flags].sort(); +}; + +const isInteractiveCommand = (source: string): boolean => { + const normalized = source.toLowerCase(); + return PROMPT_CONSTRUCTORS.some((promptCtor) => + normalized.includes(`new ${promptCtor}(`), + ); +}; + +const inferRiskLevel = (commandPath: string[]): 'low' | 'medium' | 'high' => { + const pathText = commandPath.join(' ').toLowerCase(); + + if ( + pathText.includes('delete') || + pathText.includes('clear') || + pathText.includes('update') || + pathText.includes('bulk-create') || + pathText.includes('print_on_station') + ) { + return 'high'; + } + + if ( + pathText.includes('create') || + pathText.includes('fix_template') || + pathText.includes('sync_images') || + pathText.includes('workflow') + ) { + return 'medium'; + } + + return 'low'; +}; + +const inferPreconditions = (commandPath: string[]): string[] => { + const section = commandPath[0]; + const preconditions = ['Command available in installed pwcli build']; + + if (section === 'aws' || section === 'deployment') { + preconditions.push('AWS CLI configured with valid credentials'); + } + if (section === 'workflow' || section === 'deployment') { + preconditions.push('Photowall repository configured in pwcli settings'); + } + if (section === 'ai') { + preconditions.push('AI keys and local settings configured when required'); + } + if (section === 'setup') { + preconditions.push('Interactive terminal is recommended'); + } + + return preconditions; +}; + +const inferExpectedOutput = ( + supportsJsonOutput: boolean, + interactive: boolean, +): string => { + if (supportsJsonOutput) { + return 'Structured JSON when --json is provided; human-readable text otherwise'; + } + if (interactive) { + return 'Interactive prompt flow and terminal output'; + } + return 'Human-readable terminal output'; +}; + +module.exports = { + name: 'schema', + alias: ['sc'], + description: 'Output pwcli command schema for LLMs (sc)', + hidden: false, + run: async (toolbox: GluegunMenuToolbox) => { + const { print } = toolbox; + + try { + const commandsRoot = findCommandsRoot(); + const commandFiles = collectCommandFiles(commandsRoot); + const discovered: Array<{ + module: CommandModule; + commandPath: string[]; + sourceFile: string; + sourceText: string; + }> = []; + + for (const commandFile of commandFiles) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const requiredModule = require(commandFile); + const commandModule: CommandModule = + requiredModule && requiredModule.default + ? requiredModule.default + : requiredModule; + + if ( + !commandModule || + typeof commandModule !== 'object' || + typeof commandModule.run !== 'function' || + typeof commandModule.name !== 'string' + ) { + continue; + } + + discovered.push({ + module: commandModule, + commandPath: getCommandPathFromFile(commandsRoot, commandFile), + sourceFile: path.relative(process.cwd(), commandFile), + sourceText: readSourceFile(commandFile), + }); + } + + const sortedDiscovered = discovered.sort((a, b) => { + const depthDiff = a.commandPath.length - b.commandPath.length; + if (depthDiff !== 0) return depthDiff; + return a.commandPath.join(' ').localeCompare(b.commandPath.join(' ')); + }); + + const commandLookup = new Map( + sortedDiscovered.map((entry) => [entry.commandPath.join(' '), entry]), + ); + + const schemaCommands: CommandSchema[] = sortedDiscovered.map((entry) => { + const aliases = Array.isArray(entry.module.alias) + ? entry.module.alias.filter((alias) => typeof alias === 'string') + : []; + + const detectedFlags = detectFlags(entry.sourceText); + const interactive = isInteractiveCommand(entry.sourceText); + const supportsJsonOutput = detectedFlags.includes('--json'); + const isRootPwcliCommand = + entry.commandPath.length === 1 && entry.commandPath[0] === 'pwcli'; + const invocationPath = isRootPwcliCommand + ? 'pwcli' + : ['pwcli', ...entry.commandPath].join(' '); + + const aliasSegments = entry.commandPath.map((_, index) => { + const route = entry.commandPath.slice(0, index + 1).join(' '); + const routeEntry = commandLookup.get(route); + const routeAliases = routeEntry?.module?.alias; + return Array.isArray(routeAliases) && routeAliases[0] + ? routeAliases[0] + : entry.commandPath[index]; + }); + + const aliasInvocation = isRootPwcliCommand + ? 'pwcli' + : ['pwcli', ...aliasSegments].join(' '); + const riskLevel = inferRiskLevel(entry.commandPath); + + return { + name: + entry.module.name || + entry.commandPath[entry.commandPath.length - 1], + commandPath: entry.commandPath, + aliases, + description: entry.module.description || '', + hidden: Boolean(entry.module.hidden), + sourceFile: entry.sourceFile, + detectedFlags, + interactive, + supportsJsonOutput, + riskLevel, + llmUsage: { + intent: + entry.module.description || + `Execute ${entry.commandPath.join(' ')} command`, + invocation: invocationPath, + aliasInvocation, + whenToUse: + riskLevel === 'high' + ? 'Use when explicitly requested and confirm destructive intent' + : 'Use when user intent maps to this command capability', + preconditions: inferPreconditions(entry.commandPath), + expectedOutput: inferExpectedOutput( + supportsJsonOutput, + interactive, + ), + }, + }; + }); + + const schemaPayload = { + schemaVersion: '1.0.0', + generatedAt: new Date().toISOString(), + cli: { + name: 'pwcli', + commandCount: schemaCommands.length, + }, + llmPlaybook: { + executionPattern: + 'Prefer full command paths; use alias paths for compact execution', + automationPattern: + 'Prefer commands exposing --json for deterministic machine parsing', + safetyPattern: + 'Commands marked riskLevel=high should require explicit user confirmation', + }, + commands: schemaCommands, + }; + + console.log(JSON.stringify(schemaPayload, null, 2)); + } catch (error) { + print.error( + `Failed to generate command schema: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }, +}; diff --git a/src/commands/ai/translate_excel/translate_excel.ts b/src/commands/ai/translate_excel/translate_excel.ts index e4ce7d6..2466a2f 100644 --- a/src/commands/ai/translate_excel/translate_excel.ts +++ b/src/commands/ai/translate_excel/translate_excel.ts @@ -344,6 +344,8 @@ module.exports = { 'gemini-flash-latest', 'gemini-flash-lite-latest', 'gemini-2.5-pro', + 'gemini-flash-latest', + 'gemini-flash-lite-latest', 'gemini-3-pro-preview', ]; const modelPrompt = new Select({ @@ -353,7 +355,21 @@ module.exports = { }); selectedModel = await modelPrompt.run(); } else if (selectedProvider === 'OpenAI') { - modelChoices = ['gpt-5-nano', 'gpt-5-mini', 'gpt-5.1']; + modelChoices = [ + // High quality (best for translation) + 'gpt-5', + 'gpt-4.1', + 'gpt-4o', + + // Cost / speed optimized (still strong for translation) + 'gpt-5-mini', + 'gpt-4.1-mini', + 'gpt-4o-mini', + + // Lowest cost (good for simple strings / high volume) + 'gpt-5-nano', + 'gpt-4.1-nano', + ]; const modelPrompt = new Select({ name: 'model', message: 'Select OpenAI model:',