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:
+5
-1
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+472
@@ -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'])
|
||||
|
||||
@@ -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:',
|
||||
|
||||
Reference in New Issue
Block a user