basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { CommandKind, type SlashCommand } from '../ui/commands/types.js';8 9/** Maximum number of stacked skill commands that can be loaded in one prompt. */10export const MAX_STACKED_SKILLS = 5;11 12export type ParsedSlashCommand = {13 commandToExecute: SlashCommand | undefined;14 args: string;15 canonicalPath: string[];16};17 18export type ParsedStackedSkillCommands = {19 /** All matched skill commands (up to MAX_STACKED_SKILLS). */20 skills: SlashCommand[];21 /** Text remaining after the last matched skill token. */22 remainingText: string;23 /** True when more than MAX_STACKED_SKILLS leading tokens were found. */24 exceededMax: boolean;25};26 27/**28 * Parses a raw slash command string into its command, arguments, and canonical path.29 * If no valid command is found, the `commandToExecute` property will be `undefined`.30 *31 * @param query The raw input string, e.g., "/config set theme dark" or "/help".32 * @param commands The list of available top-level slash commands.33 * @returns An object containing the resolved command, its arguments, and its canonical path.34 */35export const parseSlashCommand = (36 query: string,37 commands: readonly SlashCommand[],38): ParsedSlashCommand => {39 const trimmed = query.trim();40 41 const commandText = trimmed.substring(1).trim();42 const parts = commandText.split(/\s+/);43 const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']44 45 let currentCommands = commands;46 let commandToExecute: SlashCommand | undefined;47 let pathIndex = 0;48 const canonicalPath: string[] = [];49 let argsStart = 0;50 51 for (const part of commandPath) {52 // TODO: For better performance and architectural clarity, this two-pass53 // search could be replaced. A more optimal approach would be to54 // pre-compute a single lookup map in `CommandService.ts` that resolves55 // all name and alias conflicts during the initial loading phase. The56 // processor would then perform a single, fast lookup on that map.57 58 // First pass: check for an exact match on the primary command name.59 let foundCommand = currentCommands.find((cmd) => cmd.name === part);60 61 // Second pass: if no primary name matches, check for an alias.62 if (!foundCommand) {63 foundCommand = currentCommands.find((cmd) =>64 cmd.altNames?.includes(part),65 );66 }67 68 if (foundCommand) {69 commandToExecute = foundCommand;70 canonicalPath.push(foundCommand.name);71 pathIndex++;72 const partIndex = commandText.indexOf(part, argsStart);73 argsStart = partIndex + part.length;74 if (foundCommand.subCommands) {75 currentCommands = foundCommand.subCommands;76 } else {77 break;78 }79 } else {80 break;81 }82 }83 84 const args = commandToExecute85 ? commandText.slice(argsStart).trim()86 : parts.slice(pathIndex).join(' ');87 88 return { commandToExecute, args, canonicalPath };89};90 91/**92 * Detects multiple leading `/skill-name` tokens in user input.93 *94 * For input like `/feat-dev /e2e-testing implement X`, returns all matched95 * skill commands (up to MAX_STACKED_SKILLS) and the remaining text.96 *97 * Only matches commands with `kind === CommandKind.SKILL`. Stops at the first98 * non-skill token or unmatched `/token`.99 *100 * @param query The raw input string starting with `/`.101 * @param commands The list of available slash commands.102 * @returns Matched skill commands and the remaining text after them.103 */104export const parseStackedSlashCommands = (105 query: string,106 commands: readonly SlashCommand[],107): ParsedStackedSkillCommands => {108 const trimmed = query.trim();109 if (!trimmed.startsWith('/')) {110 return { skills: [], remainingText: trimmed, exceededMax: false };111 }112 113 const commandText = trimmed.substring(1);114 const skills: SlashCommand[] = [];115 let pos = 0;116 let restPos = 0;117 let exceededMax = false;118 119 while (pos < commandText.length) {120 // Skip whitespace between tokens (matches spaces, tabs, etc.).121 while (pos < commandText.length && /\s/.test(commandText[pos]!)) pos++;122 if (pos >= commandText.length) {123 restPos = pos;124 break;125 }126 127 const tokenStart = pos;128 while (pos < commandText.length && !/\s/.test(commandText[pos]!)) pos++;129 const token = commandText.slice(tokenStart, pos);130 131 if (skills.length === 0) {132 if (token.startsWith('/')) break;133 const cmd = findCommandByName(token, commands);134 if (!cmd || cmd.kind !== CommandKind.SKILL) break;135 skills.push(cmd);136 restPos = pos;137 continue;138 }139 140 if (!token.startsWith('/')) break;141 const name = token.substring(1);142 if (!name) break;143 144 const cmd = findCommandByName(name, commands);145 if (!cmd || cmd.kind !== CommandKind.SKILL) break;146 147 if (skills.length >= MAX_STACKED_SKILLS) {148 exceededMax = true;149 break;150 }151 152 skills.push(cmd);153 restPos = pos;154 }155 156 if (skills.length < 2) {157 return { skills: [], remainingText: trimmed, exceededMax: false };158 }159 160 const afterSkills = commandText.slice(restPos).trim();161 return { skills, remainingText: afterSkills, exceededMax };162};163 164function findCommandByName(165 name: string,166 commands: readonly SlashCommand[],167): SlashCommand | undefined {168 return (169 commands.find((cmd) => cmd.name === name) ??170 commands.find((cmd) => cmd.altNames?.includes(name))171 );172}173 