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
This commit is contained in:
Arwid Thornström
2026-02-11 09:39:22 +01:00
committed by GitHub
parent 92e69f8322
commit e78ffc1a40
4 changed files with 817 additions and 2 deletions
+472
View File
@@ -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> | void;
};
type CommandEntry = {
commandPath: string[];
aliases: string[];
description: string;
};
type CommandNode = {
children: Record<string, CommandNode>;
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<string, CommandEntry>();
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<string>([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<string | null> => {
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<string | null> => {
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<string[] | null> => {
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'])
+323
View File
@@ -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> | 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<string>();
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)
}`,
);
}
},
};
@@ -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:',