CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes45downloads
nonInteractiveCliCommands.ts651 linesDownload Raw Back to src
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import type { PartListUnion } from '@google/genai';8import {9  parseSlashCommand,10  parseStackedSlashCommands,11} from './utils/commands.js';12import {13  Logger,14  uiTelemetryService,15  type Config,16  createDebugLogger,17  recordSkillInvocation,18} from '@qwen-code/qwen-code-core';19import { CommandService } from './services/CommandService.js';20import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';21import { BundledSkillLoader } from './services/BundledSkillLoader.js';22import { FileCommandLoader } from './services/FileCommandLoader.js';23import { SavedWorkflowLoader } from './services/saved-workflow-loader.js';24import { McpPromptLoader } from './services/McpPromptLoader.js';25import { SkillCommandLoader } from './services/SkillCommandLoader.js';26import {27  type CommandContext,28  CommandKind,29  type SlashCommand,30  type SlashCommandActionReturn,31  type ExecutionMode,32} from './ui/commands/types.js';33import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';34import type { HistoryItemWithoutId } from './ui/types.js';35import type { LoadedSettings } from './config/settings.js';36import type { SessionStatsState } from './ui/contexts/SessionContext.js';37import { t } from './i18n/index.js';38import {39  appendUserPromptExpansionAdditionalContext,40  formatUserPromptExpansionBlockedMessage,41  serializeUserPromptExpansionPrompt,42} from './utils/userPromptExpansionHook.js';43 44const debugLogger = createDebugLogger('NON_INTERACTIVE_COMMANDS');45 46type CommandServiceInstance = Awaited<ReturnType<typeof CommandService.create>>;47 48function getSkillCommandName(command: SlashCommand): string {49  return command.skillDetail?.name ?? command.name;50}51 52/**53 * Result of handling a slash command in non-interactive mode.54 *55 * Supported types:56 * - 'submit_prompt': Submits content to the model (supports all modes)57 * - 'message': Returns a single message (supports non-interactive JSON/text only)58 * - 'stream_messages': Streams multiple messages (supports ACP only)59 * - 'unsupported': Command cannot be executed in this mode60 * - 'no_command': No command was found or executed61 */62export type NonInteractiveSlashCommandResult =63  | {64      type: 'submit_prompt';65      content: PartListUnion;66      outputHistoryItems?: HistoryItemWithoutId[];67      /** Per-turn model id (e.g. inline `/model <id> <prompt>`); no session change. */68      modelOverride?: string;69    }70  | {71      type: 'message';72      messageType: 'info' | 'warning' | 'error';73      content: string;74      outputHistoryItems?: HistoryItemWithoutId[];75    }76  | {77      type: 'stream_messages';78      messages: AsyncGenerator<79        { messageType: 'info' | 'warning' | 'error'; content: string },80        void,81        unknown82      >;83    }84  | {85      type: 'unsupported';86      reason: string;87      originalType: string;88    }89  | {90      type: 'no_command';91    };92 93/**94 * Converts a SlashCommandActionReturn to a NonInteractiveSlashCommandResult.95 *96 * Only the following result types are supported in non-interactive mode:97 * - submit_prompt: Submits content to the model (all modes)98 * - message: Returns a single message (non-interactive JSON/text only)99 * - stream_messages: Streams multiple messages (ACP only)100 *101 * All other result types are converted to 'unsupported'.102 *103 * @param result The result from executing a slash command action104 * @returns A NonInteractiveSlashCommandResult describing the outcome105 */106function handleCommandResult(107  result: SlashCommandActionReturn,108  outputHistoryItems?: HistoryItemWithoutId[],109): NonInteractiveSlashCommandResult {110  switch (result.type) {111    case 'submit_prompt':112      return {113        type: 'submit_prompt',114        content: result.content,115        ...(result.modelOverride116          ? { modelOverride: result.modelOverride }117          : {}),118        ...(outputHistoryItems?.length ? { outputHistoryItems } : {}),119      };120 121    case 'message':122      return {123        type: 'message',124        messageType: result.messageType,125        content: result.content,126        ...(outputHistoryItems?.length ? { outputHistoryItems } : {}),127      };128 129    case 'stream_messages':130      return {131        type: 'stream_messages',132        messages: result.messages,133      };134 135    /**136     * Currently return types below are never generated due to the137     * whitelist of allowed slash commands in ACP and non-interactive mode.138     * We'll try to add more supported return types in the future.139     */140    case 'tool':141      return {142        type: 'unsupported',143        reason:144          'Tool execution from slash commands is not supported in non-interactive mode.',145        originalType: 'tool',146      };147 148    case 'quit':149      return {150        type: 'unsupported',151        reason:152          'Quit command is not supported in non-interactive mode. The process will exit naturally after completion.',153        originalType: 'quit',154      };155 156    case 'dialog':157      return {158        type: 'unsupported',159        reason: `Dialog '${result.dialog}' cannot be opened in non-interactive mode.`,160        originalType: 'dialog',161      };162 163    case 'load_history':164      return {165        type: 'unsupported',166        reason:167          'Loading history is not supported in non-interactive mode. Each invocation starts with a fresh context.',168        originalType: 'load_history',169      };170 171    case 'confirm_shell_commands':172      return {173        type: 'unsupported',174        reason:175          'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',176        originalType: 'confirm_shell_commands',177      };178 179    case 'confirm_action':180      return {181        type: 'unsupported',182        reason:183          'Action confirmation is not supported in non-interactive mode. Commands requiring confirmation cannot be executed.',184        originalType: 'confirm_action',185      };186 187    default: {188      // Exhaustiveness check189      const _exhaustive: never = result;190      return {191        type: 'unsupported',192        reason: `Unknown command result type: ${(_exhaustive as SlashCommandActionReturn).type}`,193        originalType: 'unknown',194      };195    }196  }197}198 199async function fireUserPromptExpansionHook(200  config: Config,201  commandName: string,202  commandArgs: string,203  content: PartListUnion,204  signal: AbortSignal,205): Promise<{206  blockedResult?: NonInteractiveSlashCommandResult;207  content: PartListUnion;208}> {209  if (210    config.getDisableAllHooks?.() ||211    !(config.hasHooksForEvent?.('UserPromptExpansion') ?? false)212  ) {213    return { content };214  }215 216  const hookSystem = config.getHookSystem();217  if (!hookSystem) {218    return { content };219  }220 221  const output = await hookSystem.fireUserPromptExpansionEvent(222    commandName,223    commandArgs,224    serializeUserPromptExpansionPrompt(content),225    signal,226  );227  if (!output) {228    return { content };229  }230 231  const blockingError = output.getBlockingError();232  if (blockingError.blocked || output.shouldStopExecution()) {233    return {234      blockedResult: {235        type: 'message',236        messageType: 'error',237        content: formatUserPromptExpansionBlockedMessage(238          blockingError.reason || output.getEffectiveReason(),239        ),240      },241      content,242    };243  }244 245  return {246    content: appendUserPromptExpansionAdditionalContext(247      content,248      output.getAdditionalContext(),249    ),250  };251}252 253async function registerModelInvocableCommands(254  commandService: CommandServiceInstance,255  config: Config,256  executionMode: ExecutionMode,257  settings?: LoadedSettings,258): Promise<void> {259  if (!settings) {260    return;261  }262 263  config.setModelInvocableCommandsProvider(() =>264    commandService.getModelInvocableCommands().map((cmd) => ({265      name: cmd.name,266      description: cmd.modelDescription ?? cmd.description,267    })),268  );269 270  config.setModelInvocableCommandsExecutor(271    async (name: string, args: string = '') => {272      const commands = commandService.getModelInvocableCommands();273      const cmd = commands.find((c) => c.name === name);274      if (!cmd?.action) return null;275      const minimalContext = {276        executionMode,277        invocation: {278          raw: args ? `/${name} ${args}` : `/${name}`,279          name,280          args,281        },282        services: { config, settings, logger: null },283      } as unknown as CommandContext;284      const result = await cmd.action(minimalContext, args);285      if (!result || result.type !== 'submit_prompt') return null;286      const hookSignal = new AbortController().signal;287      const hookResult = await fireUserPromptExpansionHook(288        config,289        name,290        args,291        result.content,292        hookSignal,293      );294      if (hookResult.blockedResult) {295        return hookResult.blockedResult.type === 'message'296          ? { error: hookResult.blockedResult.content }297          : null;298      }299      const content = hookResult.content;300      if (typeof content === 'string') return content;301      if (Array.isArray(content)) {302        return content303          .map((p) =>304            typeof p === 'string' ? p : ((p as { text?: string }).text ?? ''),305          )306          .join('');307      }308      return null;309    },310  );311 312  const skillManager =313    typeof config.getSkillManager === 'function'314      ? config.getSkillManager()315      : null;316  await skillManager?.notifyConfigChanged();317}318 319/**320 * Processes a slash command in a non-interactive environment.321 *322 * @param rawQuery The raw query string (should start with '/')323 * @param abortController Controller to cancel the operation324 * @param config The configuration object325 * @param settings The loaded settings326 * @returns A Promise that resolves to a `NonInteractiveSlashCommandResult` describing327 *   the outcome of the command execution.328 */329export const handleSlashCommand = async (330  rawQuery: string,331  abortController: AbortController,332  config: Config,333  settings: LoadedSettings,334): Promise<NonInteractiveSlashCommandResult> => {335  const trimmed = rawQuery.trim();336  if (!trimmed.startsWith('/')) {337    return { type: 'no_command' };338  }339 340  const isAcpMode = config.getExperimentalZedIntegration();341  const isInteractive = config.isInteractive();342 343  const executionMode: ExecutionMode = isAcpMode344    ? 'acp'345    : isInteractive346      ? 'interactive'347      : 'non_interactive';348 349  // Load all commands to check if the command exists but is not allowed350  const allLoaders = [351    new McpPromptLoader(config),352    new BuiltinCommandLoader(config),353    new BundledSkillLoader(config),354    new SkillCommandLoader(config),355    new SavedWorkflowLoader(config),356    new FileCommandLoader(config),357  ];358 359  // Build the disabled-command set (case-insensitive).360  const disabledSlashCommandsRaw = config.getDisabledSlashCommands();361  const disabledNameSet = new Set<string>();362  for (const name of disabledSlashCommandsRaw) {363    const trimmed = name.trim();364    if (trimmed) disabledNameSet.add(trimmed.toLowerCase());365  }366  const isDisabled = (cmd: { name: string; altNames?: readonly string[] }) =>367    disabledNameSet.has(cmd.name.toLowerCase()) ||368    (cmd.altNames ?? []).some((a) => disabledNameSet.has(a.toLowerCase()));369 370  // Load the full command set (unfiltered by the denylist) so that the371  // fallback existence check below can distinguish a disabled command from a372  // truly unknown one. Without this, a disabled command would fall through to373  // `no_command` and be forwarded to the model as plain prompt text.374  const allCommandService = await CommandService.create(375    allLoaders,376    abortController.signal,377  );378  const commandService =379    disabledNameSet.size > 0380      ? await CommandService.create(381          allLoaders,382          abortController.signal,383          disabledNameSet,384        )385      : allCommandService;386  await registerModelInvocableCommands(387    commandService,388    config,389    executionMode,390    settings,391  );392  const allCommands = allCommandService.getCommands();393  const filteredCommands = commandService394    .getCommandsForMode(executionMode)395    .filter((cmd) => !isDisabled(cmd));396 397  // First, try to parse with filtered commands398  const { commandToExecute, args } = parseSlashCommand(399    rawQuery,400    filteredCommands,401  );402 403  // Handle stacked skill invocations (e.g. /feat-dev /e2e-testing implement X)404  const stackedResult = parseStackedSlashCommands(rawQuery, filteredCommands);405  if (stackedResult.skills.length >= 2) {406    const combinedContent: PartListUnion[] = [];407    let firstModelOverride: string | undefined;408    const onCompleteCallbacks: Array<() => Promise<void>> = [];409 410    for (const skill of stackedResult.skills) {411      if (!skill.action) continue;412      const skillContext = {413        executionMode,414        invocation: {415          raw: `/${skill.name}`,416          name: skill.name,417          args: '',418        },419        services: { config, settings, logger: null },420      } as unknown as CommandContext;421 422      const skillResult = await skill.action(skillContext, '');423      if (skillResult?.type === 'submit_prompt') {424        combinedContent.push(skillResult.content);425        firstModelOverride ??= skillResult.modelOverride;426        if (skillResult.onComplete) {427          onCompleteCallbacks.push(skillResult.onComplete);428        }429      }430 431      recordSkillInvocation(config, {432        skillName: getSkillCommandName(skill),433        success: skillResult?.type === 'submit_prompt',434      });435    }436 437    if (stackedResult.remainingText) {438      combinedContent.push([{ text: stackedResult.remainingText }]);439    }440 441    const mergedContent: PartListUnion = combinedContent.flat();442 443    const hookResult = await fireUserPromptExpansionHook(444      config,445      stackedResult.skills.map((s) => s.name).join(' '),446      stackedResult.remainingText,447      mergedContent,448      abortController.signal,449    );450    if (hookResult.blockedResult) {451      return hookResult.blockedResult;452    }453 454    return {455      type: 'submit_prompt',456      content: hookResult.content,457      ...(firstModelOverride ? { modelOverride: firstModelOverride } : {}),458      ...(onCompleteCallbacks.length459        ? {460            onComplete: async () => {461              for (const cb of onCompleteCallbacks) await cb();462            },463          }464        : {}),465    };466  }467 468  if (!commandToExecute) {469    // Check if this is a known command that's just not allowed470    const { commandToExecute: knownCommand } = parseSlashCommand(471      rawQuery,472      allCommands,473    );474 475    if (knownCommand) {476      // Derive the token the user actually typed (e.g. "about" when the477      // primary name is "status") to surface a helpful error message.478      const typedToken =479        rawQuery.trim().substring(1).trim().split(/\s+/)[0] ??480        knownCommand.name;481      if (isDisabled(knownCommand)) {482        return {483          type: 'unsupported',484          reason: t(485            'The command "/{{command}}" is disabled by the current configuration.',486            { command: typedToken },487          ),488          originalType: 'filtered_command',489        };490      }491      // Command exists but is not allowed in this mode492      return {493        type: 'unsupported',494        reason: t('The command "/{{command}}" is not supported in this mode.', {495          command: typedToken,496        }),497        originalType: 'filtered_command',498      };499    }500 501    return { type: 'no_command' };502  }503 504  if (!commandToExecute.action) {505    return { type: 'no_command' };506  }507 508  // Not used by custom commands but may be in the future.509  const sessionStats: SessionStatsState = {510    sessionId: config?.getSessionId(),511    sessionStartTime: new Date(),512    metrics: config513      ? uiTelemetryService.getMetricsForSession(config.getSessionId())514      : uiTelemetryService.getMetrics(),515    lastPromptTokenCount: 0,516    promptCount: 1,517  };518 519  const logger = new Logger(config?.getSessionId() || '', config?.storage);520 521  const outputHistoryItems: HistoryItemWithoutId[] = [];522  const ui = createNonInteractiveUI();523  ui.addItem = (item) => {524    outputHistoryItems.push(item);525    return 0;526  };527 528  const context: CommandContext = {529    executionMode,530    services: {531      config,532      settings,533      logger,534    },535    ui,536    session: {537      stats: sessionStats,538      sessionShellAllowlist: new Set(),539    },540    invocation: {541      raw: trimmed,542      name: commandToExecute.name,543      args,544    },545  };546 547  const isSkillCommand = commandToExecute.kind === CommandKind.SKILL;548  let skillInvocationRecorded = false;549  const recordSkillCommandInvocation = (success: boolean) => {550    if (!isSkillCommand || skillInvocationRecorded) {551      return;552    }553    recordSkillInvocation(config, {554      skillName: getSkillCommandName(commandToExecute),555      success,556    });557    skillInvocationRecorded = true;558  };559 560  let result: SlashCommandActionReturn | void;561  try {562    result = await commandToExecute.action(context, args);563  } catch (error) {564    recordSkillCommandInvocation(false);565    throw error;566  }567 568  if (!result) {569    // Command executed but returned no result (e.g., void return)570    return {571      type: 'message',572      messageType: 'info',573      content: 'Command executed successfully.',574    };575  }576 577  if (result.type === 'submit_prompt') {578    let hookResult: Awaited<ReturnType<typeof fireUserPromptExpansionHook>>;579    try {580      hookResult = await fireUserPromptExpansionHook(581        config,582        commandToExecute.name,583        args,584        result.content,585        abortController.signal,586      );587    } catch (error) {588      recordSkillCommandInvocation(false);589      throw error;590    }591    if (hookResult.blockedResult) {592      recordSkillCommandInvocation(false);593      return hookResult.blockedResult;594    }595    recordSkillCommandInvocation(true);596    return handleCommandResult(597      { ...result, content: hookResult.content },598      outputHistoryItems,599    );600  }601 602  // Handle different result types603  return handleCommandResult(result, outputHistoryItems);604};605 606/**607 * Retrieves all available slash commands for the given execution mode.608 *609 * @param config The configuration object610 * @param abortSignal Signal to cancel the loading process611 * @param mode The execution mode to filter commands for. Defaults to 'acp'.612 * @returns A Promise that resolves to an array of SlashCommand objects613 */614export const getAvailableCommands = async (615  config: Config,616  abortSignal: AbortSignal,617  mode: ExecutionMode = 'acp',618  settings?: LoadedSettings,619): Promise<SlashCommand[]> => {620  try {621    const loaders = [622      new McpPromptLoader(config),623      new BuiltinCommandLoader(config),624      new BundledSkillLoader(config),625      new SkillCommandLoader(config),626      new SavedWorkflowLoader(config),627      new FileCommandLoader(config),628    ];629 630    const disabledSlashCommands = config.getDisabledSlashCommands();631    const commandService = await CommandService.create(632      loaders,633      abortSignal,634      disabledSlashCommands.length > 0635        ? new Set(disabledSlashCommands)636        : undefined,637    );638    await registerModelInvocableCommands(639      commandService,640      config,641      mode,642      settings,643    );644    return commandService.getCommandsForMode(mode) as SlashCommand[];645  } catch (error) {646    // Handle errors gracefully - log and return empty array647    debugLogger.error('Error loading available commands:', error);648    return [];649  }650};651 
basant307/AI_Governance_Project · CoolFace