/ tools / SkillTool / SkillTool.ts
SkillTool.ts
   1  import { feature } from 'bun:bundle'
   2  import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
   3  import uniqBy from 'lodash-es/uniqBy.js'
   4  import { dirname } from 'path'
   5  import { getProjectRoot } from 'src/bootstrap/state.js'
   6  import {
   7    builtInCommandNames,
   8    findCommand,
   9    getCommands,
  10    type PromptCommand,
  11  } from 'src/commands.js'
  12  import type {
  13    Tool,
  14    ToolCallProgress,
  15    ToolResult,
  16    ToolUseContext,
  17    ValidationResult,
  18  } from 'src/Tool.js'
  19  import { buildTool, type ToolDef } from 'src/Tool.js'
  20  import type { Command } from 'src/types/command.js'
  21  import type {
  22    AssistantMessage,
  23    AttachmentMessage,
  24    Message,
  25    SystemMessage,
  26    UserMessage,
  27  } from 'src/types/message.js'
  28  import { logForDebugging } from 'src/utils/debug.js'
  29  import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js'
  30  import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js'
  31  import {
  32    isOfficialMarketplaceName,
  33    parsePluginIdentifier,
  34  } from 'src/utils/plugins/pluginIdentifier.js'
  35  import { buildPluginCommandTelemetryFields } from 'src/utils/telemetry/pluginTelemetry.js'
  36  import { z } from 'zod/v4'
  37  import {
  38    addInvokedSkill,
  39    clearInvokedSkillsForAgent,
  40    getSessionId,
  41  } from '../../bootstrap/state.js'
  42  import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js'
  43  import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
  44  import {
  45    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  46    type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
  47    logEvent,
  48  } from '../../services/analytics/index.js'
  49  import { getAgentContext } from '../../utils/agentContext.js'
  50  import { errorMessage } from '../../utils/errors.js'
  51  import {
  52    extractResultText,
  53    prepareForkedCommandContext,
  54  } from '../../utils/forkedAgent.js'
  55  import { parseFrontmatter } from '../../utils/frontmatterParser.js'
  56  import { lazySchema } from '../../utils/lazySchema.js'
  57  import { createUserMessage, normalizeMessages } from '../../utils/messages.js'
  58  import type { ModelAlias } from '../../utils/model/aliases.js'
  59  import { resolveSkillModelOverride } from '../../utils/model/model.js'
  60  import { recordSkillUsage } from '../../utils/suggestions/skillUsageTracking.js'
  61  import { createAgentId } from '../../utils/uuid.js'
  62  import { runAgent } from '../AgentTool/runAgent.js'
  63  import {
  64    getToolUseIDFromParentMessage,
  65    tagMessagesWithToolUseID,
  66  } from '../utils.js'
  67  import { SKILL_TOOL_NAME } from './constants.js'
  68  import { getPrompt } from './prompt.js'
  69  import {
  70    renderToolResultMessage,
  71    renderToolUseErrorMessage,
  72    renderToolUseMessage,
  73    renderToolUseProgressMessage,
  74    renderToolUseRejectedMessage,
  75  } from './UI.js'
  76  
  77  /**
  78   * Gets all commands including MCP skills/prompts from AppState.
  79   * SkillTool needs this because getCommands() only returns local/bundled skills.
  80   */
  81  async function getAllCommands(context: ToolUseContext): Promise<Command[]> {
  82    // Only include MCP skills (loadedFrom === 'mcp'), not plain MCP prompts.
  83    // Before this filter, the model could invoke MCP prompts via SkillTool
  84    // if it guessed the mcp__server__prompt name — they weren't discoverable
  85    // but were technically reachable.
  86    const mcpSkills = context
  87      .getAppState()
  88      .mcp.commands.filter(
  89        cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp',
  90      )
  91    if (mcpSkills.length === 0) return getCommands(getProjectRoot())
  92    const localCommands = await getCommands(getProjectRoot())
  93    return uniqBy([...localCommands, ...mcpSkills], 'name')
  94  }
  95  
  96  // Re-export Progress from centralized types to break import cycles
  97  export type { SkillToolProgress as Progress } from '../../types/tools.js'
  98  
  99  import type { SkillToolProgress as Progress } from '../../types/tools.js'
 100  
 101  // Conditional require for remote skill modules — static imports here would
 102  // pull in akiBackend.ts (via remoteSkillLoader → akiBackend), which has
 103  // module-level memoize()/lazySchema() consts that survive tree-shaking as
 104  // side-effecting initializers. All usages are inside
 105  // feature('EXPERIMENTAL_SKILL_SEARCH') guards, so remoteSkillModules is
 106  // non-null at every call site.
 107  /* eslint-disable @typescript-eslint/no-require-imports */
 108  const remoteSkillModules = feature('EXPERIMENTAL_SKILL_SEARCH')
 109    ? {
 110        ...(require('../../services/skillSearch/remoteSkillState.js') as typeof import('../../services/skillSearch/remoteSkillState.js')),
 111        ...(require('../../services/skillSearch/remoteSkillLoader.js') as typeof import('../../services/skillSearch/remoteSkillLoader.js')),
 112        ...(require('../../services/skillSearch/telemetry.js') as typeof import('../../services/skillSearch/telemetry.js')),
 113        ...(require('../../services/skillSearch/featureCheck.js') as typeof import('../../services/skillSearch/featureCheck.js')),
 114      }
 115    : null
 116  /* eslint-enable @typescript-eslint/no-require-imports */
 117  
 118  /**
 119   * Executes a skill in a forked sub-agent context.
 120   * This runs the skill prompt in an isolated agent with its own token budget.
 121   */
 122  async function executeForkedSkill(
 123    command: Command & { type: 'prompt' },
 124    commandName: string,
 125    args: string | undefined,
 126    context: ToolUseContext,
 127    canUseTool: CanUseToolFn,
 128    parentMessage: AssistantMessage,
 129    onProgress?: ToolCallProgress<Progress>,
 130  ): Promise<ToolResult<Output>> {
 131    const startTime = Date.now()
 132    const agentId = createAgentId()
 133    const isBuiltIn = builtInCommandNames().has(commandName)
 134    const isOfficialSkill = isOfficialMarketplaceSkill(command)
 135    const isBundled = command.source === 'bundled'
 136    const forkedSanitizedName =
 137      isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom'
 138  
 139    const wasDiscoveredField =
 140      feature('EXPERIMENTAL_SKILL_SEARCH') &&
 141      remoteSkillModules!.isSkillSearchEnabled()
 142        ? {
 143            was_discovered:
 144              context.discoveredSkillNames?.has(commandName) ?? false,
 145          }
 146        : {}
 147    const pluginMarketplace = command.pluginInfo
 148      ? parsePluginIdentifier(command.pluginInfo.repository).marketplace
 149      : undefined
 150    const queryDepth = context.queryTracking?.depth ?? 0
 151    const parentAgentId = getAgentContext()?.agentId
 152    logEvent('tengu_skill_tool_invocation', {
 153      command_name:
 154        forkedSanitizedName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 155      // _PROTO_skill_name routes to the privileged skill_name BQ column
 156      // (unredacted, all users); command_name stays in additional_metadata as
 157      // the redacted variant for general-access dashboards.
 158      _PROTO_skill_name:
 159        commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 160      execution_context:
 161        'fork' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 162      invocation_trigger: (queryDepth > 0
 163        ? 'nested-skill'
 164        : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 165      query_depth: queryDepth,
 166      ...(parentAgentId && {
 167        parent_agent_id:
 168          parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 169      }),
 170      ...wasDiscoveredField,
 171      ...(process.env.USER_TYPE === 'ant' && {
 172        skill_name:
 173          commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 174        skill_source:
 175          command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 176        ...(command.loadedFrom && {
 177          skill_loaded_from:
 178            command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 179        }),
 180        ...(command.kind && {
 181          skill_kind:
 182            command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 183        }),
 184      }),
 185      ...(command.pluginInfo && {
 186        // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns
 187        // (unredacted, all users); plugin_name/plugin_repository stay in
 188        // additional_metadata as redacted variants.
 189        _PROTO_plugin_name: command.pluginInfo.pluginManifest
 190          .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 191        ...(pluginMarketplace && {
 192          _PROTO_marketplace_name:
 193            pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 194        }),
 195        plugin_name: (isOfficialSkill
 196          ? command.pluginInfo.pluginManifest.name
 197          : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 198        plugin_repository: (isOfficialSkill
 199          ? command.pluginInfo.repository
 200          : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 201        ...buildPluginCommandTelemetryFields(command.pluginInfo),
 202      }),
 203    })
 204  
 205    const { modifiedGetAppState, baseAgent, promptMessages, skillContent } =
 206      await prepareForkedCommandContext(command, args || '', context)
 207  
 208    // Merge skill's effort into the agent definition so runAgent applies it
 209    const agentDefinition =
 210      command.effort !== undefined
 211        ? { ...baseAgent, effort: command.effort }
 212        : baseAgent
 213  
 214    // Collect messages from the forked agent
 215    const agentMessages: Message[] = []
 216  
 217    logForDebugging(
 218      `SkillTool executing forked skill ${commandName} with agent ${agentDefinition.agentType}`,
 219    )
 220  
 221    try {
 222      // Run the sub-agent
 223      for await (const message of runAgent({
 224        agentDefinition,
 225        promptMessages,
 226        toolUseContext: {
 227          ...context,
 228          getAppState: modifiedGetAppState,
 229        },
 230        canUseTool,
 231        isAsync: false,
 232        querySource: 'agent:custom',
 233        model: command.model as ModelAlias | undefined,
 234        availableTools: context.options.tools,
 235        override: { agentId },
 236      })) {
 237        agentMessages.push(message)
 238  
 239        // Report progress for tool uses (like AgentTool does)
 240        if (
 241          (message.type === 'assistant' || message.type === 'user') &&
 242          onProgress
 243        ) {
 244          const normalizedNew = normalizeMessages([message])
 245          for (const m of normalizedNew) {
 246            const hasToolContent = m.message.content.some(
 247              c => c.type === 'tool_use' || c.type === 'tool_result',
 248            )
 249            if (hasToolContent) {
 250              onProgress({
 251                toolUseID: `skill_${parentMessage.message.id}`,
 252                data: {
 253                  message: m,
 254                  type: 'skill_progress',
 255                  prompt: skillContent,
 256                  agentId,
 257                },
 258              })
 259            }
 260          }
 261        }
 262      }
 263  
 264      const resultText = extractResultText(
 265        agentMessages,
 266        'Skill execution completed',
 267      )
 268      // Release message memory after extracting result
 269      agentMessages.length = 0
 270  
 271      const durationMs = Date.now() - startTime
 272      logForDebugging(
 273        `SkillTool forked skill ${commandName} completed in ${durationMs}ms`,
 274      )
 275  
 276      return {
 277        data: {
 278          success: true,
 279          commandName,
 280          status: 'forked',
 281          agentId,
 282          result: resultText,
 283        },
 284      }
 285    } finally {
 286      // Release skill content from invokedSkills state
 287      clearInvokedSkillsForAgent(agentId)
 288    }
 289  }
 290  
 291  export const inputSchema = lazySchema(() =>
 292    z.object({
 293      skill: z
 294        .string()
 295        .describe('The skill name. E.g., "commit", "review-pr", or "pdf"'),
 296      args: z.string().optional().describe('Optional arguments for the skill'),
 297    }),
 298  )
 299  type InputSchema = ReturnType<typeof inputSchema>
 300  
 301  export const outputSchema = lazySchema(() => {
 302    // Output schema for inline skills (default)
 303    const inlineOutputSchema = z.object({
 304      success: z.boolean().describe('Whether the skill is valid'),
 305      commandName: z.string().describe('The name of the skill'),
 306      allowedTools: z
 307        .array(z.string())
 308        .optional()
 309        .describe('Tools allowed by this skill'),
 310      model: z.string().optional().describe('Model override if specified'),
 311      status: z.literal('inline').optional().describe('Execution status'),
 312    })
 313  
 314    // Output schema for forked skills
 315    const forkedOutputSchema = z.object({
 316      success: z.boolean().describe('Whether the skill completed successfully'),
 317      commandName: z.string().describe('The name of the skill'),
 318      status: z.literal('forked').describe('Execution status'),
 319      agentId: z
 320        .string()
 321        .describe('The ID of the sub-agent that executed the skill'),
 322      result: z.string().describe('The result from the forked skill execution'),
 323    })
 324  
 325    return z.union([inlineOutputSchema, forkedOutputSchema])
 326  })
 327  type OutputSchema = ReturnType<typeof outputSchema>
 328  
 329  export type Output = z.input<OutputSchema>
 330  
 331  export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({
 332    name: SKILL_TOOL_NAME,
 333    searchHint: 'invoke a slash-command skill',
 334    maxResultSizeChars: 100_000,
 335    get inputSchema(): InputSchema {
 336      return inputSchema()
 337    },
 338    get outputSchema(): OutputSchema {
 339      return outputSchema()
 340    },
 341  
 342    description: async ({ skill }) => `Execute skill: ${skill}`,
 343  
 344    prompt: async () => getPrompt(getProjectRoot()),
 345  
 346    // Only one skill/command should run at a time, since the tool expands the
 347    // command into a full prompt that Claude must process before continuing.
 348    // Skill-coach needs the skill name to avoid false-positive "you could have
 349    // used skill X" suggestions when X was actually invoked. Backseat classifies
 350    // downstream tool calls from the expanded prompt, not this wrapper, so the
 351    // name alone is sufficient — it just records that the skill fired.
 352    toAutoClassifierInput: ({ skill }) => skill ?? '',
 353  
 354    async validateInput({ skill }, context): Promise<ValidationResult> {
 355      // Skills are just skill names, no arguments
 356      const trimmed = skill.trim()
 357      if (!trimmed) {
 358        return {
 359          result: false,
 360          message: `Invalid skill format: ${skill}`,
 361          errorCode: 1,
 362        }
 363      }
 364  
 365      // Remove leading slash if present (for compatibility)
 366      const hasLeadingSlash = trimmed.startsWith('/')
 367      if (hasLeadingSlash) {
 368        logEvent('tengu_skill_tool_slash_prefix', {})
 369      }
 370      const normalizedCommandName = hasLeadingSlash
 371        ? trimmed.substring(1)
 372        : trimmed
 373  
 374      // Remote canonical skill handling (ant-only experimental). Intercept
 375      // `_canonical_<slug>` names before local command lookup since remote
 376      // skills are not in the local command registry.
 377      if (
 378        feature('EXPERIMENTAL_SKILL_SEARCH') &&
 379        process.env.USER_TYPE === 'ant'
 380      ) {
 381        const slug = remoteSkillModules!.stripCanonicalPrefix(
 382          normalizedCommandName,
 383        )
 384        if (slug !== null) {
 385          const meta = remoteSkillModules!.getDiscoveredRemoteSkill(slug)
 386          if (!meta) {
 387            return {
 388              result: false,
 389              message: `Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`,
 390              errorCode: 6,
 391            }
 392          }
 393          // Discovered remote skill — valid. Loading happens in call().
 394          return { result: true }
 395        }
 396      }
 397  
 398      // Get available commands (including MCP skills)
 399      const commands = await getAllCommands(context)
 400  
 401      // Check if command exists
 402      const foundCommand = findCommand(normalizedCommandName, commands)
 403      if (!foundCommand) {
 404        return {
 405          result: false,
 406          message: `Unknown skill: ${normalizedCommandName}`,
 407          errorCode: 2,
 408        }
 409      }
 410  
 411      // Check if command has model invocation disabled
 412      if (foundCommand.disableModelInvocation) {
 413        return {
 414          result: false,
 415          message: `Skill ${normalizedCommandName} cannot be used with ${SKILL_TOOL_NAME} tool due to disable-model-invocation`,
 416          errorCode: 4,
 417        }
 418      }
 419  
 420      // Check if command is a prompt-based command
 421      if (foundCommand.type !== 'prompt') {
 422        return {
 423          result: false,
 424          message: `Skill ${normalizedCommandName} is not a prompt-based skill`,
 425          errorCode: 5,
 426        }
 427      }
 428  
 429      return { result: true }
 430    },
 431  
 432    async checkPermissions(
 433      { skill, args },
 434      context,
 435    ): Promise<PermissionDecision> {
 436      // Skills are just skill names, no arguments
 437      const trimmed = skill.trim()
 438  
 439      // Remove leading slash if present (for compatibility)
 440      const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed
 441  
 442      const appState = context.getAppState()
 443      const permissionContext = appState.toolPermissionContext
 444  
 445      // Look up the command object to pass as metadata
 446      const commands = await getAllCommands(context)
 447      const commandObj = findCommand(commandName, commands)
 448  
 449      // Helper function to check if a rule matches the skill
 450      // Normalizes both inputs by stripping leading slashes for consistent matching
 451      const ruleMatches = (ruleContent: string): boolean => {
 452        // Normalize rule content by stripping leading slash
 453        const normalizedRule = ruleContent.startsWith('/')
 454          ? ruleContent.substring(1)
 455          : ruleContent
 456  
 457        // Check exact match (using normalized commandName)
 458        if (normalizedRule === commandName) {
 459          return true
 460        }
 461        // Check prefix match (e.g., "review:*" matches "review-pr 123")
 462        if (normalizedRule.endsWith(':*')) {
 463          const prefix = normalizedRule.slice(0, -2) // Remove ':*'
 464          return commandName.startsWith(prefix)
 465        }
 466        return false
 467      }
 468  
 469      // Check for deny rules
 470      const denyRules = getRuleByContentsForTool(
 471        permissionContext,
 472        SkillTool as Tool,
 473        'deny',
 474      )
 475      for (const [ruleContent, rule] of denyRules.entries()) {
 476        if (ruleMatches(ruleContent)) {
 477          return {
 478            behavior: 'deny',
 479            message: `Skill execution blocked by permission rules`,
 480            decisionReason: {
 481              type: 'rule',
 482              rule,
 483            },
 484          }
 485        }
 486      }
 487  
 488      // Remote canonical skills are ant-only experimental — auto-grant.
 489      // Placed AFTER the deny loop so a user-configured Skill(_canonical_:*)
 490      // deny rule is honored (same pattern as safe-properties auto-allow below).
 491      // The skill content itself is canonical/curated, not user-authored.
 492      if (
 493        feature('EXPERIMENTAL_SKILL_SEARCH') &&
 494        process.env.USER_TYPE === 'ant'
 495      ) {
 496        const slug = remoteSkillModules!.stripCanonicalPrefix(commandName)
 497        if (slug !== null) {
 498          return {
 499            behavior: 'allow',
 500            updatedInput: { skill, args },
 501            decisionReason: undefined,
 502          }
 503        }
 504      }
 505  
 506      // Check for allow rules
 507      const allowRules = getRuleByContentsForTool(
 508        permissionContext,
 509        SkillTool as Tool,
 510        'allow',
 511      )
 512      for (const [ruleContent, rule] of allowRules.entries()) {
 513        if (ruleMatches(ruleContent)) {
 514          return {
 515            behavior: 'allow',
 516            updatedInput: { skill, args },
 517            decisionReason: {
 518              type: 'rule',
 519              rule,
 520            },
 521          }
 522        }
 523      }
 524  
 525      // Auto-allow skills that only use safe properties.
 526      // This is an allowlist: if a skill has any property NOT in this set with a
 527      // meaningful value, it requires permission. This ensures new properties added
 528      // in the future default to requiring permission.
 529      if (
 530        commandObj?.type === 'prompt' &&
 531        skillHasOnlySafeProperties(commandObj)
 532      ) {
 533        return {
 534          behavior: 'allow',
 535          updatedInput: { skill, args },
 536          decisionReason: undefined,
 537        }
 538      }
 539  
 540      // Prepare suggestions for exact skill and prefix
 541      // Use normalized commandName (without leading slash) for consistent rules
 542      const suggestions = [
 543        // Exact skill suggestion
 544        {
 545          type: 'addRules' as const,
 546          rules: [
 547            {
 548              toolName: SKILL_TOOL_NAME,
 549              ruleContent: commandName,
 550            },
 551          ],
 552          behavior: 'allow' as const,
 553          destination: 'localSettings' as const,
 554        },
 555        // Prefix suggestion to allow any args
 556        {
 557          type: 'addRules' as const,
 558          rules: [
 559            {
 560              toolName: SKILL_TOOL_NAME,
 561              ruleContent: `${commandName}:*`,
 562            },
 563          ],
 564          behavior: 'allow' as const,
 565          destination: 'localSettings' as const,
 566        },
 567      ]
 568  
 569      // Default behavior: ask user for permission
 570      return {
 571        behavior: 'ask',
 572        message: `Execute skill: ${commandName}`,
 573        decisionReason: undefined,
 574        suggestions,
 575        updatedInput: { skill, args },
 576        metadata: commandObj ? { command: commandObj } : undefined,
 577      }
 578    },
 579  
 580    async call(
 581      { skill, args },
 582      context,
 583      canUseTool,
 584      parentMessage,
 585      onProgress?,
 586    ): Promise<ToolResult<Output>> {
 587      // At this point, validateInput has already confirmed:
 588      // - Skill format is valid
 589      // - Skill exists
 590      // - Skill can be loaded
 591      // - Skill doesn't have disableModelInvocation
 592      // - Skill is a prompt-based skill
 593  
 594      // Skills are just names, with optional arguments
 595      const trimmed = skill.trim()
 596  
 597      // Remove leading slash if present (for compatibility)
 598      const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed
 599  
 600      // Remote canonical skill execution (ant-only experimental). Intercepts
 601      // `_canonical_<slug>` before local command lookup — loads SKILL.md from
 602      // AKI/GCS (with local cache), injects content directly as a user message.
 603      // Remote skills are declarative markdown so no slash-command expansion
 604      // (no !command substitution, no $ARGUMENTS interpolation) is needed.
 605      if (
 606        feature('EXPERIMENTAL_SKILL_SEARCH') &&
 607        process.env.USER_TYPE === 'ant'
 608      ) {
 609        const slug = remoteSkillModules!.stripCanonicalPrefix(commandName)
 610        if (slug !== null) {
 611          return executeRemoteSkill(slug, commandName, parentMessage, context)
 612        }
 613      }
 614  
 615      const commands = await getAllCommands(context)
 616      const command = findCommand(commandName, commands)
 617  
 618      // Track skill usage for ranking
 619      recordSkillUsage(commandName)
 620  
 621      // Check if skill should run as a forked sub-agent
 622      if (command?.type === 'prompt' && command.context === 'fork') {
 623        return executeForkedSkill(
 624          command,
 625          commandName,
 626          args,
 627          context,
 628          canUseTool,
 629          parentMessage,
 630          onProgress,
 631        )
 632      }
 633  
 634      // Process the skill with optional args
 635      const { processPromptSlashCommand } = await import(
 636        'src/utils/processUserInput/processSlashCommand.js'
 637      )
 638      const processedCommand = await processPromptSlashCommand(
 639        commandName,
 640        args || '', // Pass args if provided
 641        commands,
 642        context,
 643      )
 644  
 645      if (!processedCommand.shouldQuery) {
 646        throw new Error('Command processing failed')
 647      }
 648  
 649      // Extract metadata from the command
 650      const allowedTools = processedCommand.allowedTools || []
 651      const model = processedCommand.model
 652      const effort = command?.type === 'prompt' ? command.effort : undefined
 653  
 654      const isBuiltIn = builtInCommandNames().has(commandName)
 655      const isBundled = command?.type === 'prompt' && command.source === 'bundled'
 656      const isOfficialSkill =
 657        command?.type === 'prompt' && isOfficialMarketplaceSkill(command)
 658      const sanitizedCommandName =
 659        isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom'
 660  
 661      const wasDiscoveredField =
 662        feature('EXPERIMENTAL_SKILL_SEARCH') &&
 663        remoteSkillModules!.isSkillSearchEnabled()
 664          ? {
 665              was_discovered:
 666                context.discoveredSkillNames?.has(commandName) ?? false,
 667            }
 668          : {}
 669      const pluginMarketplace =
 670        command?.type === 'prompt' && command.pluginInfo
 671          ? parsePluginIdentifier(command.pluginInfo.repository).marketplace
 672          : undefined
 673      const queryDepth = context.queryTracking?.depth ?? 0
 674      const parentAgentId = getAgentContext()?.agentId
 675      logEvent('tengu_skill_tool_invocation', {
 676        command_name:
 677          sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 678        // _PROTO_skill_name routes to the privileged skill_name BQ column
 679        // (unredacted, all users); command_name stays in additional_metadata as
 680        // the redacted variant for general-access dashboards.
 681        _PROTO_skill_name:
 682          commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 683        execution_context:
 684          'inline' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 685        invocation_trigger: (queryDepth > 0
 686          ? 'nested-skill'
 687          : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 688        query_depth: queryDepth,
 689        ...(parentAgentId && {
 690          parent_agent_id:
 691            parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 692        }),
 693        ...wasDiscoveredField,
 694        ...(process.env.USER_TYPE === 'ant' && {
 695          skill_name:
 696            commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 697          ...(command?.type === 'prompt' && {
 698            skill_source:
 699              command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 700          }),
 701          ...(command?.loadedFrom && {
 702            skill_loaded_from:
 703              command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 704          }),
 705          ...(command?.kind && {
 706            skill_kind:
 707              command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 708          }),
 709        }),
 710        ...(command?.type === 'prompt' &&
 711          command.pluginInfo && {
 712            _PROTO_plugin_name: command.pluginInfo.pluginManifest
 713              .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 714            ...(pluginMarketplace && {
 715              _PROTO_marketplace_name:
 716                pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 717            }),
 718            plugin_name: (isOfficialSkill
 719              ? command.pluginInfo.pluginManifest.name
 720              : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 721            plugin_repository: (isOfficialSkill
 722              ? command.pluginInfo.repository
 723              : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 724            ...buildPluginCommandTelemetryFields(command.pluginInfo),
 725          }),
 726      })
 727  
 728      // Get the tool use ID from the parent message for linking newMessages
 729      const toolUseID = getToolUseIDFromParentMessage(
 730        parentMessage,
 731        SKILL_TOOL_NAME,
 732      )
 733  
 734      // Tag user messages with sourceToolUseID so they stay transient until this tool resolves
 735      const newMessages = tagMessagesWithToolUseID(
 736        processedCommand.messages.filter(
 737          (m): m is UserMessage | AttachmentMessage | SystemMessage => {
 738            if (m.type === 'progress') {
 739              return false
 740            }
 741            // Filter out command-message since SkillTool handles display
 742            if (m.type === 'user' && 'message' in m) {
 743              const content = m.message.content
 744              if (
 745                typeof content === 'string' &&
 746                content.includes(`<${COMMAND_MESSAGE_TAG}>`)
 747              ) {
 748                return false
 749              }
 750            }
 751            return true
 752          },
 753        ),
 754        toolUseID,
 755      )
 756  
 757      logForDebugging(
 758        `SkillTool returning ${newMessages.length} newMessages for skill ${commandName}`,
 759      )
 760  
 761      // Note: addInvokedSkill and registerSkillHooks are called inside
 762      // processPromptSlashCommand (via getMessagesForPromptSlashCommand), so
 763      // calling them again here would double-register hooks and rebuild
 764      // skillContent redundantly.
 765  
 766      // Return success with newMessages and contextModifier
 767      return {
 768        data: {
 769          success: true,
 770          commandName,
 771          allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
 772          model,
 773        },
 774        newMessages,
 775        contextModifier(ctx) {
 776          let modifiedContext = ctx
 777  
 778          // Update allowed tools if specified
 779          if (allowedTools.length > 0) {
 780            // Capture the current getAppState to chain modifications properly
 781            const previousGetAppState = modifiedContext.getAppState
 782            modifiedContext = {
 783              ...modifiedContext,
 784              getAppState() {
 785                // Use the previous getAppState, not the closure's context.getAppState,
 786                // to properly chain context modifications
 787                const appState = previousGetAppState()
 788                return {
 789                  ...appState,
 790                  toolPermissionContext: {
 791                    ...appState.toolPermissionContext,
 792                    alwaysAllowRules: {
 793                      ...appState.toolPermissionContext.alwaysAllowRules,
 794                      command: [
 795                        ...new Set([
 796                          ...(appState.toolPermissionContext.alwaysAllowRules
 797                            .command || []),
 798                          ...allowedTools,
 799                        ]),
 800                      ],
 801                    },
 802                  },
 803                }
 804              },
 805            }
 806          }
 807  
 808          // Carry [1m] suffix over — otherwise a skill with `model: opus` on an
 809          // opus[1m] session drops the effective window to 200K and trips autocompact.
 810          if (model) {
 811            modifiedContext = {
 812              ...modifiedContext,
 813              options: {
 814                ...modifiedContext.options,
 815                mainLoopModel: resolveSkillModelOverride(
 816                  model,
 817                  ctx.options.mainLoopModel,
 818                ),
 819              },
 820            }
 821          }
 822  
 823          // Override effort level if skill specifies one
 824          if (effort !== undefined) {
 825            const previousGetAppState = modifiedContext.getAppState
 826            modifiedContext = {
 827              ...modifiedContext,
 828              getAppState() {
 829                const appState = previousGetAppState()
 830                return {
 831                  ...appState,
 832                  effortValue: effort,
 833                }
 834              },
 835            }
 836          }
 837  
 838          return modifiedContext
 839        },
 840      }
 841    },
 842  
 843    mapToolResultToToolResultBlockParam(
 844      result: Output,
 845      toolUseID: string,
 846    ): ToolResultBlockParam {
 847      // Handle forked skill result
 848      if ('status' in result && result.status === 'forked') {
 849        return {
 850          type: 'tool_result' as const,
 851          tool_use_id: toolUseID,
 852          content: `Skill "${result.commandName}" completed (forked execution).\n\nResult:\n${result.result}`,
 853        }
 854      }
 855  
 856      // Inline skill result (default)
 857      return {
 858        type: 'tool_result' as const,
 859        tool_use_id: toolUseID,
 860        content: `Launching skill: ${result.commandName}`,
 861      }
 862    },
 863  
 864    renderToolResultMessage,
 865    renderToolUseMessage,
 866    renderToolUseProgressMessage,
 867    renderToolUseRejectedMessage,
 868    renderToolUseErrorMessage,
 869  } satisfies ToolDef<InputSchema, Output, Progress>)
 870  
 871  // Allowlist of PromptCommand property keys that are safe and don't require permission.
 872  // If a skill has any property NOT in this set with a meaningful value, it requires
 873  // permission. This ensures new properties added to PromptCommand in the future
 874  // default to requiring permission until explicitly reviewed and added here.
 875  const SAFE_SKILL_PROPERTIES = new Set([
 876    // PromptCommand properties
 877    'type',
 878    'progressMessage',
 879    'contentLength',
 880    'argNames',
 881    'model',
 882    'effort',
 883    'source',
 884    'pluginInfo',
 885    'disableNonInteractive',
 886    'skillRoot',
 887    'context',
 888    'agent',
 889    'getPromptForCommand',
 890    'frontmatterKeys',
 891    // CommandBase properties
 892    'name',
 893    'description',
 894    'hasUserSpecifiedDescription',
 895    'isEnabled',
 896    'isHidden',
 897    'aliases',
 898    'isMcp',
 899    'argumentHint',
 900    'whenToUse',
 901    'paths',
 902    'version',
 903    'disableModelInvocation',
 904    'userInvocable',
 905    'loadedFrom',
 906    'immediate',
 907    'userFacingName',
 908  ])
 909  
 910  function skillHasOnlySafeProperties(command: Command): boolean {
 911    for (const key of Object.keys(command)) {
 912      if (SAFE_SKILL_PROPERTIES.has(key)) {
 913        continue
 914      }
 915      // Property not in safe allowlist - check if it has a meaningful value
 916      const value = (command as Record<string, unknown>)[key]
 917      if (value === undefined || value === null) {
 918        continue
 919      }
 920      if (Array.isArray(value) && value.length === 0) {
 921        continue
 922      }
 923      if (
 924        typeof value === 'object' &&
 925        !Array.isArray(value) &&
 926        Object.keys(value).length === 0
 927      ) {
 928        continue
 929      }
 930      return false
 931    }
 932    return true
 933  }
 934  
 935  function isOfficialMarketplaceSkill(command: PromptCommand): boolean {
 936    if (command.source !== 'plugin' || !command.pluginInfo?.repository) {
 937      return false
 938    }
 939    return isOfficialMarketplaceName(
 940      parsePluginIdentifier(command.pluginInfo.repository).marketplace,
 941    )
 942  }
 943  
 944  /**
 945   * Extract URL scheme for telemetry. Defaults to 'gs' for unrecognized schemes
 946   * since the AKI backend is the only production path and the loader throws on
 947   * unknown schemes before we reach telemetry anyway.
 948   */
 949  function extractUrlScheme(url: string): 'gs' | 'http' | 'https' | 's3' {
 950    if (url.startsWith('gs://')) return 'gs'
 951    if (url.startsWith('https://')) return 'https'
 952    if (url.startsWith('http://')) return 'http'
 953    if (url.startsWith('s3://')) return 's3'
 954    return 'gs'
 955  }
 956  
 957  /**
 958   * Load a remote canonical skill and inject its SKILL.md content into the
 959   * conversation. Unlike local skills (which go through processPromptSlashCommand
 960   * for !command / $ARGUMENTS expansion), remote skills are declarative markdown
 961   * — we wrap the content directly in a user message.
 962   *
 963   * The skill is also registered with addInvokedSkill so it survives compaction
 964   * (same as local skills).
 965   *
 966   * Only called from within a feature('EXPERIMENTAL_SKILL_SEARCH') guard in
 967   * call() — remoteSkillModules is non-null here.
 968   */
 969  async function executeRemoteSkill(
 970    slug: string,
 971    commandName: string,
 972    parentMessage: AssistantMessage,
 973    context: ToolUseContext,
 974  ): Promise<ToolResult<Output>> {
 975    const { getDiscoveredRemoteSkill, loadRemoteSkill, logRemoteSkillLoaded } =
 976      remoteSkillModules!
 977  
 978    // validateInput already confirmed this slug is in session state, but we
 979    // re-fetch here to get the URL. If it's somehow gone (e.g., state cleared
 980    // mid-session), fail with a clear error rather than crashing.
 981    const meta = getDiscoveredRemoteSkill(slug)
 982    if (!meta) {
 983      throw new Error(
 984        `Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`,
 985      )
 986    }
 987  
 988    const urlScheme = extractUrlScheme(meta.url)
 989    let loadResult
 990    try {
 991      loadResult = await loadRemoteSkill(slug, meta.url)
 992    } catch (e) {
 993      const msg = errorMessage(e)
 994      logRemoteSkillLoaded({
 995        slug,
 996        cacheHit: false,
 997        latencyMs: 0,
 998        urlScheme,
 999        error: msg,
1000      })
1001      throw new Error(`Failed to load remote skill ${slug}: ${msg}`)
1002    }
1003  
1004    const {
1005      cacheHit,
1006      latencyMs,
1007      skillPath,
1008      content,
1009      fileCount,
1010      totalBytes,
1011      fetchMethod,
1012    } = loadResult
1013  
1014    logRemoteSkillLoaded({
1015      slug,
1016      cacheHit,
1017      latencyMs,
1018      urlScheme,
1019      fileCount,
1020      totalBytes,
1021      fetchMethod,
1022    })
1023  
1024    // Remote skills are always model-discovered (never in static skill_listing),
1025    // so was_discovered is always true. is_remote lets BQ queries separate
1026    // remote from local invocations without joining on skill name prefixes.
1027    const queryDepth = context.queryTracking?.depth ?? 0
1028    const parentAgentId = getAgentContext()?.agentId
1029    logEvent('tengu_skill_tool_invocation', {
1030      command_name:
1031        'remote_skill' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1032      // _PROTO_skill_name routes to the privileged skill_name BQ column
1033      // (unredacted, all users); command_name stays in additional_metadata as
1034      // the redacted variant.
1035      _PROTO_skill_name:
1036        commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
1037      execution_context:
1038        'remote' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1039      invocation_trigger: (queryDepth > 0
1040        ? 'nested-skill'
1041        : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1042      query_depth: queryDepth,
1043      ...(parentAgentId && {
1044        parent_agent_id:
1045          parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1046      }),
1047      was_discovered: true,
1048      is_remote: true,
1049      remote_cache_hit: cacheHit,
1050      remote_load_latency_ms: latencyMs,
1051      ...(process.env.USER_TYPE === 'ant' && {
1052        skill_name:
1053          commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1054        remote_slug:
1055          slug as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1056      }),
1057    })
1058  
1059    recordSkillUsage(commandName)
1060  
1061    logForDebugging(
1062      `SkillTool loaded remote skill ${slug} (cacheHit=${cacheHit}, ${latencyMs}ms, ${content.length} chars)`,
1063    )
1064  
1065    // Strip YAML frontmatter (---\nname: x\n---) before prepending the header
1066    // (matches loadSkillsDir.ts:333). parseFrontmatter returns the original
1067    // content unchanged if no frontmatter is present.
1068    const { content: bodyContent } = parseFrontmatter(content, skillPath)
1069  
1070    // Inject base directory header + ${CLAUDE_SKILL_DIR}/${CLAUDE_SESSION_ID}
1071    // substitution (matches loadSkillsDir.ts) so the model can resolve relative
1072    // refs like ./schemas/foo.json against the cache dir.
1073    const skillDir = dirname(skillPath)
1074    const normalizedDir =
1075      process.platform === 'win32' ? skillDir.replace(/\\/g, '/') : skillDir
1076    let finalContent = `Base directory for this skill: ${normalizedDir}\n\n${bodyContent}`
1077    finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, normalizedDir)
1078    finalContent = finalContent.replace(
1079      /\$\{CLAUDE_SESSION_ID\}/g,
1080      getSessionId(),
1081    )
1082  
1083    // Register with compaction-preservation state. Use the cached file path so
1084    // post-compact restoration knows where the content came from. Must use
1085    // finalContent (not raw content) so the base directory header and
1086    // ${CLAUDE_SKILL_DIR} substitutions survive compaction — matches how local
1087    // skills store their already-transformed content via processSlashCommand.
1088    addInvokedSkill(
1089      commandName,
1090      skillPath,
1091      finalContent,
1092      getAgentContext()?.agentId ?? null,
1093    )
1094  
1095    // Direct injection — wrap SKILL.md content in a meta user message. Matches
1096    // the shape of what processPromptSlashCommand produces for simple skills.
1097    const toolUseID = getToolUseIDFromParentMessage(
1098      parentMessage,
1099      SKILL_TOOL_NAME,
1100    )
1101    return {
1102      data: { success: true, commandName, status: 'inline' },
1103      newMessages: tagMessagesWithToolUseID(
1104        [createUserMessage({ content: finalContent, isMeta: true })],
1105        toolUseID,
1106      ),
1107    }
1108  }