Download src/tools/SkillTool/SkillTool.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 38.2 kB
-
https://huggingface.co/chenbhao/codev/resolve/e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/SkillTool/SkillTool.ts
- Command line
-
hf download hf://chenbhao/codev@e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/SkillTool/SkillTool.ts
-
curl -L -o SkillTool.ts https://huggingface.co/chenbhao/codev/resolve/e91c8197c9918ff9e5df3140e8c36282bb30e9a2/src/tools/SkillTool/SkillTool.ts
38.2 kB
| import { feature } from 'bun:bundle' | |
| import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' | |
| import uniqBy from 'lodash-es/uniqBy.js' | |
| import { dirname } from 'path' | |
| import { getProjectRoot } from 'src/bootstrap/state.js' | |
| import { | |
| builtInCommandNames, | |
| findCommand, | |
| getCommands, | |
| type PromptCommand, | |
| } from 'src/commands.js' | |
| import type { | |
| Tool, | |
| ToolCallProgress, | |
| ToolResult, | |
| ToolUseContext, | |
| ValidationResult, | |
| } from 'src/Tool.js' | |
| import { buildTool, type ToolDef } from 'src/Tool.js' | |
| import type { Command } from 'src/types/command.js' | |
| import type { | |
| AssistantMessage, | |
| AttachmentMessage, | |
| Message, | |
| SystemMessage, | |
| UserMessage, | |
| } from 'src/types/message.js' | |
| import { logForDebugging } from 'src/utils/debug.js' | |
| import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js' | |
| import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js' | |
| import { | |
| isOfficialMarketplaceName, | |
| parsePluginIdentifier, | |
| } from 'src/utils/plugins/pluginIdentifier.js' | |
| import { buildPluginCommandTelemetryFields } from 'src/utils/telemetry/pluginTelemetry.js' | |
| import { z } from 'zod/v4' | |
| import { | |
| addInvokedSkill, | |
| clearInvokedSkillsForAgent, | |
| getSessionId, | |
| } from '../../bootstrap/state.js' | |
| import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js' | |
| import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' | |
| import { | |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| logEvent, | |
| } from '../../services/analytics/index.js' | |
| import { getAgentContext } from '../../utils/agentContext.js' | |
| import { errorMessage } from '../../utils/errors.js' | |
| import { | |
| extractResultText, | |
| prepareForkedCommandContext, | |
| } from '../../utils/forkedAgent.js' | |
| import { parseFrontmatter } from '../../utils/frontmatterParser.js' | |
| import { lazySchema } from '../../utils/lazySchema.js' | |
| import { createUserMessage, normalizeMessages } from '../../utils/messages.js' | |
| import type { ModelAlias } from '../../utils/model/aliases.js' | |
| import { resolveSkillModelOverride } from '../../utils/model/model.js' | |
| import { recordSkillUsage } from '../../utils/suggestions/skillUsageTracking.js' | |
| import { createAgentId } from '../../utils/uuid.js' | |
| import { runAgent } from '../AgentTool/runAgent.js' | |
| import { | |
| getToolUseIDFromParentMessage, | |
| tagMessagesWithToolUseID, | |
| } from '../utils.js' | |
| import { SKILL_TOOL_NAME } from './constants.js' | |
| import { getPrompt } from './prompt.js' | |
| import { | |
| renderToolResultMessage, | |
| renderToolUseErrorMessage, | |
| renderToolUseMessage, | |
| renderToolUseProgressMessage, | |
| renderToolUseRejectedMessage, | |
| } from './UI.js' | |
| /** | |
| * Gets all commands including MCP skills/prompts from AppState. | |
| * SkillTool needs this because getCommands() only returns local/bundled skills. | |
| */ | |
| async function getAllCommands(context: ToolUseContext): Promise<Command[]> { | |
| // Only include MCP skills (loadedFrom === 'mcp'), not plain MCP prompts. | |
| // Before this filter, the model could invoke MCP prompts via SkillTool | |
| // if it guessed the mcp__server__prompt name — they weren't discoverable | |
| // but were technically reachable. | |
| const mcpSkills = context | |
| .getAppState() | |
| .mcp.commands.filter( | |
| cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp', | |
| ) | |
| if (mcpSkills.length === 0) return getCommands(getProjectRoot()) | |
| const localCommands = await getCommands(getProjectRoot()) | |
| return uniqBy([...localCommands, ...mcpSkills], 'name') | |
| } | |
| // Re-export Progress from centralized types to break import cycles | |
| export type { SkillToolProgress as Progress } from '../../types/tools.js' | |
| import type { SkillToolProgress as Progress } from '../../types/tools.js' | |
| // Conditional require for remote skill modules — static imports here would | |
| // pull in akiBackend.ts (via remoteSkillLoader → akiBackend), which has | |
| // module-level memoize()/lazySchema() consts that survive tree-shaking as | |
| // side-effecting initializers. All usages are inside | |
| // feature('EXPERIMENTAL_SKILL_SEARCH') guards, so remoteSkillModules is | |
| // non-null at every call site. | |
| /* eslint-disable @typescript-eslint/no-require-imports */ | |
| const remoteSkillModules = feature('EXPERIMENTAL_SKILL_SEARCH') | |
| ? { | |
| ...(require('../../services/skillSearch/remoteSkillState.js') as typeof import('../../services/skillSearch/remoteSkillState.js')), | |
| ...(require('../../services/skillSearch/remoteSkillLoader.js') as typeof import('../../services/skillSearch/remoteSkillLoader.js')), | |
| ...(require('../../services/skillSearch/telemetry.js') as typeof import('../../services/skillSearch/telemetry.js')), | |
| ...(require('../../services/skillSearch/featureCheck.js') as typeof import('../../services/skillSearch/featureCheck.js')), | |
| } | |
| : null | |
| /* eslint-enable @typescript-eslint/no-require-imports */ | |
| /** | |
| * Executes a skill in a forked sub-agent context. | |
| * This runs the skill prompt in an isolated agent with its own token budget. | |
| */ | |
| async function executeForkedSkill( | |
| command: Command & { type: 'prompt' }, | |
| commandName: string, | |
| args: string | undefined, | |
| context: ToolUseContext, | |
| canUseTool: CanUseToolFn, | |
| parentMessage: AssistantMessage, | |
| onProgress?: ToolCallProgress<Progress>, | |
| ): Promise<ToolResult<Output>> { | |
| const startTime = Date.now() | |
| const agentId = createAgentId() | |
| const isBuiltIn = builtInCommandNames().has(commandName) | |
| const isOfficialSkill = isOfficialMarketplaceSkill(command) | |
| const isBundled = command.source === 'bundled' | |
| const forkedSanitizedName = | |
| isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom' | |
| const wasDiscoveredField = | |
| feature('EXPERIMENTAL_SKILL_SEARCH') && | |
| remoteSkillModules!.isSkillSearchEnabled() | |
| ? { | |
| was_discovered: | |
| context.discoveredSkillNames?.has(commandName) ?? false, | |
| } | |
| : {} | |
| const pluginMarketplace = command.pluginInfo | |
| ? parsePluginIdentifier(command.pluginInfo.repository).marketplace | |
| : undefined | |
| const queryDepth = context.queryTracking?.depth ?? 0 | |
| const parentAgentId = getAgentContext()?.agentId | |
| logEvent('tengu_skill_tool_invocation', { | |
| command_name: | |
| forkedSanitizedName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| // _PROTO_skill_name routes to the privileged skill_name BQ column | |
| // (unredacted, all users); command_name stays in additional_metadata as | |
| // the redacted variant for general-access dashboards. | |
| _PROTO_skill_name: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| execution_context: | |
| 'fork' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| invocation_trigger: (queryDepth > 0 | |
| ? 'nested-skill' | |
| : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| query_depth: queryDepth, | |
| ...(parentAgentId && { | |
| parent_agent_id: | |
| parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| ...wasDiscoveredField, | |
| ...(process.env.USER_TYPE === 'ant' && { | |
| skill_name: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| skill_source: | |
| command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| ...(command.loadedFrom && { | |
| skill_loaded_from: | |
| command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| ...(command.kind && { | |
| skill_kind: | |
| command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| }), | |
| ...(command.pluginInfo && { | |
| // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns | |
| // (unredacted, all users); plugin_name/plugin_repository stay in | |
| // additional_metadata as redacted variants. | |
| _PROTO_plugin_name: command.pluginInfo.pluginManifest | |
| .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| ...(pluginMarketplace && { | |
| _PROTO_marketplace_name: | |
| pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| }), | |
| plugin_name: (isOfficialSkill | |
| ? command.pluginInfo.pluginManifest.name | |
| : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| plugin_repository: (isOfficialSkill | |
| ? command.pluginInfo.repository | |
| : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| ...buildPluginCommandTelemetryFields(command.pluginInfo), | |
| }), | |
| }) | |
| const { modifiedGetAppState, baseAgent, promptMessages, skillContent } = | |
| await prepareForkedCommandContext(command, args || '', context) | |
| // Merge skill's effort into the agent definition so runAgent applies it | |
| const agentDefinition = | |
| command.effort !== undefined | |
| ? { ...baseAgent, effort: command.effort } | |
| : baseAgent | |
| // Collect messages from the forked agent | |
| const agentMessages: Message[] = [] | |
| logForDebugging( | |
| `SkillTool executing forked skill ${commandName} with agent ${agentDefinition.agentType}`, | |
| ) | |
| try { | |
| // Run the sub-agent | |
| for await (const message of runAgent({ | |
| agentDefinition, | |
| promptMessages, | |
| toolUseContext: { | |
| ...context, | |
| getAppState: modifiedGetAppState, | |
| }, | |
| canUseTool, | |
| isAsync: false, | |
| querySource: 'agent:custom', | |
| model: command.model as ModelAlias | undefined, | |
| availableTools: context.options.tools, | |
| override: { agentId }, | |
| })) { | |
| agentMessages.push(message) | |
| // Report progress for tool uses (like AgentTool does) | |
| if ( | |
| (message.type === 'assistant' || message.type === 'user') && | |
| onProgress | |
| ) { | |
| const normalizedNew = normalizeMessages([message]) | |
| for (const m of normalizedNew) { | |
| const hasToolContent = m.message.content.some( | |
| c => c.type === 'tool_use' || c.type === 'tool_result', | |
| ) | |
| if (hasToolContent) { | |
| onProgress({ | |
| toolUseID: `skill_${parentMessage.message.id}`, | |
| data: { | |
| message: m, | |
| type: 'skill_progress', | |
| prompt: skillContent, | |
| agentId, | |
| }, | |
| }) | |
| } | |
| } | |
| } | |
| } | |
| const resultText = extractResultText( | |
| agentMessages, | |
| 'Skill execution completed', | |
| ) | |
| // Release message memory after extracting result | |
| agentMessages.length = 0 | |
| const durationMs = Date.now() - startTime | |
| logForDebugging( | |
| `SkillTool forked skill ${commandName} completed in ${durationMs}ms`, | |
| ) | |
| return { | |
| data: { | |
| success: true, | |
| commandName, | |
| status: 'forked', | |
| agentId, | |
| result: resultText, | |
| }, | |
| } | |
| } finally { | |
| // Release skill content from invokedSkills state | |
| clearInvokedSkillsForAgent(agentId) | |
| } | |
| } | |
| export const inputSchema = lazySchema(() => | |
| z.object({ | |
| skill: z | |
| .string() | |
| .describe('The skill name. E.g., "commit", "review-pr", or "pdf"'), | |
| args: z.string().optional().describe('Optional arguments for the skill'), | |
| }), | |
| ) | |
| type InputSchema = ReturnType<typeof inputSchema> | |
| export const outputSchema = lazySchema(() => { | |
| // Output schema for inline skills (default) | |
| const inlineOutputSchema = z.object({ | |
| success: z.boolean().describe('Whether the skill is valid'), | |
| commandName: z.string().describe('The name of the skill'), | |
| allowedTools: z | |
| .array(z.string()) | |
| .optional() | |
| .describe('Tools allowed by this skill'), | |
| model: z.string().optional().describe('Model override if specified'), | |
| status: z.literal('inline').optional().describe('Execution status'), | |
| }) | |
| // Output schema for forked skills | |
| const forkedOutputSchema = z.object({ | |
| success: z.boolean().describe('Whether the skill completed successfully'), | |
| commandName: z.string().describe('The name of the skill'), | |
| status: z.literal('forked').describe('Execution status'), | |
| agentId: z | |
| .string() | |
| .describe('The ID of the sub-agent that executed the skill'), | |
| result: z.string().describe('The result from the forked skill execution'), | |
| }) | |
| return z.union([inlineOutputSchema, forkedOutputSchema]) | |
| }) | |
| type OutputSchema = ReturnType<typeof outputSchema> | |
| export type Output = z.input<OutputSchema> | |
| export const SkillTool: Tool<InputSchema, Output, Progress> = buildTool({ | |
| name: SKILL_TOOL_NAME, | |
| searchHint: 'invoke a slash-command skill', | |
| maxResultSizeChars: 100_000, | |
| get inputSchema(): InputSchema { | |
| return inputSchema() | |
| }, | |
| get outputSchema(): OutputSchema { | |
| return outputSchema() | |
| }, | |
| description: async ({ skill }) => `Execute skill: ${skill}`, | |
| prompt: async () => getPrompt(getProjectRoot()), | |
| // Only one skill/command should run at a time, since the tool expands the | |
| // command into a full prompt that Claude must process before continuing. | |
| // Skill-coach needs the skill name to avoid false-positive "you could have | |
| // used skill X" suggestions when X was actually invoked. Backseat classifies | |
| // downstream tool calls from the expanded prompt, not this wrapper, so the | |
| // name alone is sufficient — it just records that the skill fired. | |
| toAutoClassifierInput: ({ skill }) => skill ?? '', | |
| async validateInput({ skill }, context): Promise<ValidationResult> { | |
| // Skills are just skill names, no arguments | |
| const trimmed = skill.trim() | |
| if (!trimmed) { | |
| return { | |
| result: false, | |
| message: `Invalid skill format: ${skill}`, | |
| errorCode: 1, | |
| } | |
| } | |
| // Remove leading slash if present (for compatibility) | |
| const hasLeadingSlash = trimmed.startsWith('/') | |
| if (hasLeadingSlash) { | |
| logEvent('tengu_skill_tool_slash_prefix', {}) | |
| } | |
| const normalizedCommandName = hasLeadingSlash | |
| ? trimmed.substring(1) | |
| : trimmed | |
| // Remote canonical skill handling (ant-only experimental). Intercept | |
| // `_canonical_<slug>` names before local command lookup since remote | |
| // skills are not in the local command registry. | |
| if ( | |
| feature('EXPERIMENTAL_SKILL_SEARCH') && | |
| process.env.USER_TYPE === 'ant' | |
| ) { | |
| const slug = remoteSkillModules!.stripCanonicalPrefix( | |
| normalizedCommandName, | |
| ) | |
| if (slug !== null) { | |
| const meta = remoteSkillModules!.getDiscoveredRemoteSkill(slug) | |
| if (!meta) { | |
| return { | |
| result: false, | |
| message: `Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`, | |
| errorCode: 6, | |
| } | |
| } | |
| // Discovered remote skill — valid. Loading happens in call(). | |
| return { result: true } | |
| } | |
| } | |
| // Get available commands (including MCP skills) | |
| const commands = await getAllCommands(context) | |
| // Check if command exists | |
| const foundCommand = findCommand(normalizedCommandName, commands) | |
| if (!foundCommand) { | |
| return { | |
| result: false, | |
| message: `Unknown skill: ${normalizedCommandName}`, | |
| errorCode: 2, | |
| } | |
| } | |
| // Check if command has model invocation disabled | |
| if (foundCommand.disableModelInvocation) { | |
| return { | |
| result: false, | |
| message: `Skill ${normalizedCommandName} cannot be used with ${SKILL_TOOL_NAME} tool due to disable-model-invocation`, | |
| errorCode: 4, | |
| } | |
| } | |
| // Check if command is a prompt-based command | |
| if (foundCommand.type !== 'prompt') { | |
| return { | |
| result: false, | |
| message: `Skill ${normalizedCommandName} is not a prompt-based skill`, | |
| errorCode: 5, | |
| } | |
| } | |
| return { result: true } | |
| }, | |
| async checkPermissions( | |
| { skill, args }, | |
| context, | |
| ): Promise<PermissionDecision> { | |
| // Skills are just skill names, no arguments | |
| const trimmed = skill.trim() | |
| // Remove leading slash if present (for compatibility) | |
| const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed | |
| const appState = context.getAppState() | |
| const permissionContext = appState.toolPermissionContext | |
| // Look up the command object to pass as metadata | |
| const commands = await getAllCommands(context) | |
| const commandObj = findCommand(commandName, commands) | |
| // Helper function to check if a rule matches the skill | |
| // Normalizes both inputs by stripping leading slashes for consistent matching | |
| const ruleMatches = (ruleContent: string): boolean => { | |
| // Normalize rule content by stripping leading slash | |
| const normalizedRule = ruleContent.startsWith('/') | |
| ? ruleContent.substring(1) | |
| : ruleContent | |
| // Check exact match (using normalized commandName) | |
| if (normalizedRule === commandName) { | |
| return true | |
| } | |
| // Check prefix match (e.g., "review:*" matches "review-pr 123") | |
| if (normalizedRule.endsWith(':*')) { | |
| const prefix = normalizedRule.slice(0, -2) // Remove ':*' | |
| return commandName.startsWith(prefix) | |
| } | |
| return false | |
| } | |
| // Check for deny rules | |
| const denyRules = getRuleByContentsForTool( | |
| permissionContext, | |
| SkillTool as Tool, | |
| 'deny', | |
| ) | |
| for (const [ruleContent, rule] of denyRules.entries()) { | |
| if (ruleMatches(ruleContent)) { | |
| return { | |
| behavior: 'deny', | |
| message: `Skill execution blocked by permission rules`, | |
| decisionReason: { | |
| type: 'rule', | |
| rule, | |
| }, | |
| } | |
| } | |
| } | |
| // Remote canonical skills are ant-only experimental — auto-grant. | |
| // Placed AFTER the deny loop so a user-configured Skill(_canonical_:*) | |
| // deny rule is honored (same pattern as safe-properties auto-allow below). | |
| // The skill content itself is canonical/curated, not user-authored. | |
| if ( | |
| feature('EXPERIMENTAL_SKILL_SEARCH') && | |
| process.env.USER_TYPE === 'ant' | |
| ) { | |
| const slug = remoteSkillModules!.stripCanonicalPrefix(commandName) | |
| if (slug !== null) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: { skill, args }, | |
| decisionReason: undefined, | |
| } | |
| } | |
| } | |
| // Check for allow rules | |
| const allowRules = getRuleByContentsForTool( | |
| permissionContext, | |
| SkillTool as Tool, | |
| 'allow', | |
| ) | |
| for (const [ruleContent, rule] of allowRules.entries()) { | |
| if (ruleMatches(ruleContent)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: { skill, args }, | |
| decisionReason: { | |
| type: 'rule', | |
| rule, | |
| }, | |
| } | |
| } | |
| } | |
| // Auto-allow skills that only use safe properties. | |
| // This is an allowlist: if a skill has any property NOT in this set with a | |
| // meaningful value, it requires permission. This ensures new properties added | |
| // in the future default to requiring permission. | |
| if ( | |
| commandObj?.type === 'prompt' && | |
| skillHasOnlySafeProperties(commandObj) | |
| ) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: { skill, args }, | |
| decisionReason: undefined, | |
| } | |
| } | |
| // Prepare suggestions for exact skill and prefix | |
| // Use normalized commandName (without leading slash) for consistent rules | |
| const suggestions = [ | |
| // Exact skill suggestion | |
| { | |
| type: 'addRules' as const, | |
| rules: [ | |
| { | |
| toolName: SKILL_TOOL_NAME, | |
| ruleContent: commandName, | |
| }, | |
| ], | |
| behavior: 'allow' as const, | |
| destination: 'localSettings' as const, | |
| }, | |
| // Prefix suggestion to allow any args | |
| { | |
| type: 'addRules' as const, | |
| rules: [ | |
| { | |
| toolName: SKILL_TOOL_NAME, | |
| ruleContent: `${commandName}:*`, | |
| }, | |
| ], | |
| behavior: 'allow' as const, | |
| destination: 'localSettings' as const, | |
| }, | |
| ] | |
| // Default behavior: ask user for permission | |
| return { | |
| behavior: 'ask', | |
| message: `Execute skill: ${commandName}`, | |
| decisionReason: undefined, | |
| suggestions, | |
| updatedInput: { skill, args }, | |
| metadata: commandObj ? { command: commandObj } : undefined, | |
| } | |
| }, | |
| async call( | |
| { skill, args }, | |
| context, | |
| canUseTool, | |
| parentMessage, | |
| onProgress?, | |
| ): Promise<ToolResult<Output>> { | |
| // At this point, validateInput has already confirmed: | |
| // - Skill format is valid | |
| // - Skill exists | |
| // - Skill can be loaded | |
| // - Skill doesn't have disableModelInvocation | |
| // - Skill is a prompt-based skill | |
| // Skills are just names, with optional arguments | |
| const trimmed = skill.trim() | |
| // Remove leading slash if present (for compatibility) | |
| const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed | |
| // Remote canonical skill execution (ant-only experimental). Intercepts | |
| // `_canonical_<slug>` before local command lookup — loads SKILL.md from | |
| // AKI/GCS (with local cache), injects content directly as a user message. | |
| // Remote skills are declarative markdown so no slash-command expansion | |
| // (no !command substitution, no $ARGUMENTS interpolation) is needed. | |
| if ( | |
| feature('EXPERIMENTAL_SKILL_SEARCH') && | |
| process.env.USER_TYPE === 'ant' | |
| ) { | |
| const slug = remoteSkillModules!.stripCanonicalPrefix(commandName) | |
| if (slug !== null) { | |
| return executeRemoteSkill(slug, commandName, parentMessage, context) | |
| } | |
| } | |
| const commands = await getAllCommands(context) | |
| const command = findCommand(commandName, commands) | |
| // Track skill usage for ranking | |
| recordSkillUsage(commandName) | |
| // Check if skill should run as a forked sub-agent | |
| if (command?.type === 'prompt' && command.context === 'fork') { | |
| return executeForkedSkill( | |
| command, | |
| commandName, | |
| args, | |
| context, | |
| canUseTool, | |
| parentMessage, | |
| onProgress, | |
| ) | |
| } | |
| // Process the skill with optional args | |
| const { processPromptSlashCommand } = await import( | |
| 'src/utils/processUserInput/processSlashCommand.js' | |
| ) | |
| const processedCommand = await processPromptSlashCommand( | |
| commandName, | |
| args || '', // Pass args if provided | |
| commands, | |
| context, | |
| ) | |
| if (!processedCommand.shouldQuery) { | |
| throw new Error('Command processing failed') | |
| } | |
| // Extract metadata from the command | |
| const allowedTools = processedCommand.allowedTools || [] | |
| const model = processedCommand.model | |
| const effort = command?.type === 'prompt' ? command.effort : undefined | |
| const isBuiltIn = builtInCommandNames().has(commandName) | |
| const isBundled = command?.type === 'prompt' && command.source === 'bundled' | |
| const isOfficialSkill = | |
| command?.type === 'prompt' && isOfficialMarketplaceSkill(command) | |
| const sanitizedCommandName = | |
| isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom' | |
| const wasDiscoveredField = | |
| feature('EXPERIMENTAL_SKILL_SEARCH') && | |
| remoteSkillModules!.isSkillSearchEnabled() | |
| ? { | |
| was_discovered: | |
| context.discoveredSkillNames?.has(commandName) ?? false, | |
| } | |
| : {} | |
| const pluginMarketplace = | |
| command?.type === 'prompt' && command.pluginInfo | |
| ? parsePluginIdentifier(command.pluginInfo.repository).marketplace | |
| : undefined | |
| const queryDepth = context.queryTracking?.depth ?? 0 | |
| const parentAgentId = getAgentContext()?.agentId | |
| logEvent('tengu_skill_tool_invocation', { | |
| command_name: | |
| sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| // _PROTO_skill_name routes to the privileged skill_name BQ column | |
| // (unredacted, all users); command_name stays in additional_metadata as | |
| // the redacted variant for general-access dashboards. | |
| _PROTO_skill_name: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| execution_context: | |
| 'inline' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| invocation_trigger: (queryDepth > 0 | |
| ? 'nested-skill' | |
| : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| query_depth: queryDepth, | |
| ...(parentAgentId && { | |
| parent_agent_id: | |
| parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| ...wasDiscoveredField, | |
| ...(process.env.USER_TYPE === 'ant' && { | |
| skill_name: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| ...(command?.type === 'prompt' && { | |
| skill_source: | |
| command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| ...(command?.loadedFrom && { | |
| skill_loaded_from: | |
| command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| ...(command?.kind && { | |
| skill_kind: | |
| command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| }), | |
| ...(command?.type === 'prompt' && | |
| command.pluginInfo && { | |
| _PROTO_plugin_name: command.pluginInfo.pluginManifest | |
| .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| ...(pluginMarketplace && { | |
| _PROTO_marketplace_name: | |
| pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| }), | |
| plugin_name: (isOfficialSkill | |
| ? command.pluginInfo.pluginManifest.name | |
| : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| plugin_repository: (isOfficialSkill | |
| ? command.pluginInfo.repository | |
| : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| ...buildPluginCommandTelemetryFields(command.pluginInfo), | |
| }), | |
| }) | |
| // Get the tool use ID from the parent message for linking newMessages | |
| const toolUseID = getToolUseIDFromParentMessage( | |
| parentMessage, | |
| SKILL_TOOL_NAME, | |
| ) | |
| // Tag user messages with sourceToolUseID so they stay transient until this tool resolves | |
| const newMessages = tagMessagesWithToolUseID( | |
| processedCommand.messages.filter( | |
| (m): m is UserMessage | AttachmentMessage | SystemMessage => { | |
| if (m.type === 'progress') { | |
| return false | |
| } | |
| // Filter out command-message since SkillTool handles display | |
| if (m.type === 'user' && 'message' in m) { | |
| const content = m.message.content | |
| if ( | |
| typeof content === 'string' && | |
| content.includes(`<${COMMAND_MESSAGE_TAG}>`) | |
| ) { | |
| return false | |
| } | |
| } | |
| return true | |
| }, | |
| ), | |
| toolUseID, | |
| ) | |
| logForDebugging( | |
| `SkillTool returning ${newMessages.length} newMessages for skill ${commandName}`, | |
| ) | |
| // Note: addInvokedSkill and registerSkillHooks are called inside | |
| // processPromptSlashCommand (via getMessagesForPromptSlashCommand), so | |
| // calling them again here would double-register hooks and rebuild | |
| // skillContent redundantly. | |
| // Return success with newMessages and contextModifier | |
| return { | |
| data: { | |
| success: true, | |
| commandName, | |
| allowedTools: allowedTools.length > 0 ? allowedTools : undefined, | |
| model, | |
| }, | |
| newMessages, | |
| contextModifier(ctx) { | |
| let modifiedContext = ctx | |
| // Update allowed tools if specified | |
| if (allowedTools.length > 0) { | |
| // Capture the current getAppState to chain modifications properly | |
| const previousGetAppState = modifiedContext.getAppState | |
| modifiedContext = { | |
| ...modifiedContext, | |
| getAppState() { | |
| // Use the previous getAppState, not the closure's context.getAppState, | |
| // to properly chain context modifications | |
| const appState = previousGetAppState() | |
| return { | |
| ...appState, | |
| toolPermissionContext: { | |
| ...appState.toolPermissionContext, | |
| alwaysAllowRules: { | |
| ...appState.toolPermissionContext.alwaysAllowRules, | |
| command: [ | |
| ...new Set([ | |
| ...(appState.toolPermissionContext.alwaysAllowRules | |
| .command || []), | |
| ...allowedTools, | |
| ]), | |
| ], | |
| }, | |
| }, | |
| } | |
| }, | |
| } | |
| } | |
| // Carry [1m] suffix over — otherwise a skill with `model: opus` on an | |
| // opus[1m] session drops the effective window to 200K and trips autocompact. | |
| if (model) { | |
| modifiedContext = { | |
| ...modifiedContext, | |
| options: { | |
| ...modifiedContext.options, | |
| mainLoopModel: resolveSkillModelOverride( | |
| model, | |
| ctx.options.mainLoopModel, | |
| ), | |
| }, | |
| } | |
| } | |
| // Override effort level if skill specifies one | |
| if (effort !== undefined) { | |
| const previousGetAppState = modifiedContext.getAppState | |
| modifiedContext = { | |
| ...modifiedContext, | |
| getAppState() { | |
| const appState = previousGetAppState() | |
| return { | |
| ...appState, | |
| effortValue: effort, | |
| } | |
| }, | |
| } | |
| } | |
| return modifiedContext | |
| }, | |
| } | |
| }, | |
| mapToolResultToToolResultBlockParam( | |
| result: Output, | |
| toolUseID: string, | |
| ): ToolResultBlockParam { | |
| // Handle forked skill result | |
| if ('status' in result && result.status === 'forked') { | |
| return { | |
| type: 'tool_result' as const, | |
| tool_use_id: toolUseID, | |
| content: `Skill "${result.commandName}" completed (forked execution).\n\nResult:\n${result.result}`, | |
| } | |
| } | |
| // Inline skill result (default) | |
| return { | |
| type: 'tool_result' as const, | |
| tool_use_id: toolUseID, | |
| content: `Launching skill: ${result.commandName}`, | |
| } | |
| }, | |
| renderToolResultMessage, | |
| renderToolUseMessage, | |
| renderToolUseProgressMessage, | |
| renderToolUseRejectedMessage, | |
| renderToolUseErrorMessage, | |
| } satisfies ToolDef<InputSchema, Output, Progress>) | |
| // Allowlist of PromptCommand property keys that are safe and don't require permission. | |
| // If a skill has any property NOT in this set with a meaningful value, it requires | |
| // permission. This ensures new properties added to PromptCommand in the future | |
| // default to requiring permission until explicitly reviewed and added here. | |
| const SAFE_SKILL_PROPERTIES = new Set([ | |
| // PromptCommand properties | |
| 'type', | |
| 'progressMessage', | |
| 'contentLength', | |
| 'argNames', | |
| 'model', | |
| 'effort', | |
| 'source', | |
| 'pluginInfo', | |
| 'disableNonInteractive', | |
| 'skillRoot', | |
| 'context', | |
| 'agent', | |
| 'getPromptForCommand', | |
| 'frontmatterKeys', | |
| // CommandBase properties | |
| 'name', | |
| 'description', | |
| 'hasUserSpecifiedDescription', | |
| 'isEnabled', | |
| 'isHidden', | |
| 'aliases', | |
| 'isMcp', | |
| 'argumentHint', | |
| 'whenToUse', | |
| 'paths', | |
| 'version', | |
| 'disableModelInvocation', | |
| 'userInvocable', | |
| 'loadedFrom', | |
| 'immediate', | |
| 'userFacingName', | |
| ]) | |
| function skillHasOnlySafeProperties(command: Command): boolean { | |
| for (const key of Object.keys(command)) { | |
| if (SAFE_SKILL_PROPERTIES.has(key)) { | |
| continue | |
| } | |
| // Property not in safe allowlist - check if it has a meaningful value | |
| const value = (command as Record<string, unknown>)[key] | |
| if (value === undefined || value === null) { | |
| continue | |
| } | |
| if (Array.isArray(value) && value.length === 0) { | |
| continue | |
| } | |
| if ( | |
| typeof value === 'object' && | |
| !Array.isArray(value) && | |
| Object.keys(value).length === 0 | |
| ) { | |
| continue | |
| } | |
| return false | |
| } | |
| return true | |
| } | |
| function isOfficialMarketplaceSkill(command: PromptCommand): boolean { | |
| if (command.source !== 'plugin' || !command.pluginInfo?.repository) { | |
| return false | |
| } | |
| return isOfficialMarketplaceName( | |
| parsePluginIdentifier(command.pluginInfo.repository).marketplace, | |
| ) | |
| } | |
| /** | |
| * Extract URL scheme for telemetry. Defaults to 'gs' for unrecognized schemes | |
| * since the AKI backend is the only production path and the loader throws on | |
| * unknown schemes before we reach telemetry anyway. | |
| */ | |
| function extractUrlScheme(url: string): 'gs' | 'http' | 'https' | 's3' { | |
| if (url.startsWith('gs://')) return 'gs' | |
| if (url.startsWith('https://')) return 'https' | |
| if (url.startsWith('http://')) return 'http' | |
| if (url.startsWith('s3://')) return 's3' | |
| return 'gs' | |
| } | |
| /** | |
| * Load a remote canonical skill and inject its SKILL.md content into the | |
| * conversation. Unlike local skills (which go through processPromptSlashCommand | |
| * for !command / $ARGUMENTS expansion), remote skills are declarative markdown | |
| * — we wrap the content directly in a user message. | |
| * | |
| * The skill is also registered with addInvokedSkill so it survives compaction | |
| * (same as local skills). | |
| * | |
| * Only called from within a feature('EXPERIMENTAL_SKILL_SEARCH') guard in | |
| * call() — remoteSkillModules is non-null here. | |
| */ | |
| async function executeRemoteSkill( | |
| slug: string, | |
| commandName: string, | |
| parentMessage: AssistantMessage, | |
| context: ToolUseContext, | |
| ): Promise<ToolResult<Output>> { | |
| const { getDiscoveredRemoteSkill, loadRemoteSkill, logRemoteSkillLoaded } = | |
| remoteSkillModules! | |
| // validateInput already confirmed this slug is in session state, but we | |
| // re-fetch here to get the URL. If it's somehow gone (e.g., state cleared | |
| // mid-session), fail with a clear error rather than crashing. | |
| const meta = getDiscoveredRemoteSkill(slug) | |
| if (!meta) { | |
| throw new Error( | |
| `Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`, | |
| ) | |
| } | |
| const urlScheme = extractUrlScheme(meta.url) | |
| let loadResult | |
| try { | |
| loadResult = await loadRemoteSkill(slug, meta.url) | |
| } catch (e) { | |
| const msg = errorMessage(e) | |
| logRemoteSkillLoaded({ | |
| slug, | |
| cacheHit: false, | |
| latencyMs: 0, | |
| urlScheme, | |
| error: msg, | |
| }) | |
| throw new Error(`Failed to load remote skill ${slug}: ${msg}`) | |
| } | |
| const { | |
| cacheHit, | |
| latencyMs, | |
| skillPath, | |
| content, | |
| fileCount, | |
| totalBytes, | |
| fetchMethod, | |
| } = loadResult | |
| logRemoteSkillLoaded({ | |
| slug, | |
| cacheHit, | |
| latencyMs, | |
| urlScheme, | |
| fileCount, | |
| totalBytes, | |
| fetchMethod, | |
| }) | |
| // Remote skills are always model-discovered (never in static skill_listing), | |
| // so was_discovered is always true. is_remote lets BQ queries separate | |
| // remote from local invocations without joining on skill name prefixes. | |
| const queryDepth = context.queryTracking?.depth ?? 0 | |
| const parentAgentId = getAgentContext()?.agentId | |
| logEvent('tengu_skill_tool_invocation', { | |
| command_name: | |
| 'remote_skill' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| // _PROTO_skill_name routes to the privileged skill_name BQ column | |
| // (unredacted, all users); command_name stays in additional_metadata as | |
| // the redacted variant. | |
| _PROTO_skill_name: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| execution_context: | |
| 'remote' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| invocation_trigger: (queryDepth > 0 | |
| ? 'nested-skill' | |
| : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| query_depth: queryDepth, | |
| ...(parentAgentId && { | |
| parent_agent_id: | |
| parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| was_discovered: true, | |
| is_remote: true, | |
| remote_cache_hit: cacheHit, | |
| remote_load_latency_ms: latencyMs, | |
| ...(process.env.USER_TYPE === 'ant' && { | |
| skill_name: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| remote_slug: | |
| slug as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| }) | |
| recordSkillUsage(commandName) | |
| logForDebugging( | |
| `SkillTool loaded remote skill ${slug} (cacheHit=${cacheHit}, ${latencyMs}ms, ${content.length} chars)`, | |
| ) | |
| // Strip YAML frontmatter (---\nname: x\n---) before prepending the header | |
| // (matches loadSkillsDir.ts:333). parseFrontmatter returns the original | |
| // content unchanged if no frontmatter is present. | |
| const { content: bodyContent } = parseFrontmatter(content, skillPath) | |
| // Inject base directory header + ${CLAUDE_SKILL_DIR}/${CLAUDE_SESSION_ID} | |
| // substitution (matches loadSkillsDir.ts) so the model can resolve relative | |
| // refs like ./schemas/foo.json against the cache dir. | |
| const skillDir = dirname(skillPath) | |
| const normalizedDir = | |
| process.platform === 'win32' ? skillDir.replace(/\\/g, '/') : skillDir | |
| let finalContent = `Base directory for this skill: ${normalizedDir}\n\n${bodyContent}` | |
| finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, normalizedDir) | |
| finalContent = finalContent.replace( | |
| /\$\{CLAUDE_SESSION_ID\}/g, | |
| getSessionId(), | |
| ) | |
| // Register with compaction-preservation state. Use the cached file path so | |
| // post-compact restoration knows where the content came from. Must use | |
| // finalContent (not raw content) so the base directory header and | |
| // ${CLAUDE_SKILL_DIR} substitutions survive compaction — matches how local | |
| // skills store their already-transformed content via processSlashCommand. | |
| addInvokedSkill( | |
| commandName, | |
| skillPath, | |
| finalContent, | |
| getAgentContext()?.agentId ?? null, | |
| ) | |
| // Direct injection — wrap SKILL.md content in a meta user message. Matches | |
| // the shape of what processPromptSlashCommand produces for simple skills. | |
| const toolUseID = getToolUseIDFromParentMessage( | |
| parentMessage, | |
| SKILL_TOOL_NAME, | |
| ) | |
| return { | |
| data: { success: true, commandName, status: 'inline' }, | |
| newMessages: tagMessagesWithToolUseID( | |
| [createUserMessage({ content: finalContent, isMeta: true })], | |
| toolUseID, | |
| ), | |
| } | |
| } | |