SkillTool.ts
tools/SkillTool/SkillTool.ts
1109
Lines
38175
Bytes
4
Exports
37
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is part of the tool layer, which means it describes actions the system can perform for the user or model.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with tool-system. It contains 1109 lines, 37 detected imports, and 4 detected exports.
Important relationships
Detected exports
inputSchemaoutputSchemaOutputSkillTool
Keywords
skillcommandcommandnameanalyticsmetadata_i_verified_this_is_not_code_or_filepathscontextmessagecontentskillsslugresult
Detected imports
bun:bundle@anthropic-ai/sdk/resources/index.mjslodash-es/uniqBy.jspathsrc/bootstrap/state.jssrc/commands.jssrc/Tool.jssrc/Tool.jssrc/types/command.jssrc/types/message.jssrc/utils/debug.jssrc/utils/permissions/PermissionResult.jssrc/utils/permissions/permissions.jssrc/utils/plugins/pluginIdentifier.jssrc/utils/telemetry/pluginTelemetry.jszod/v4../../bootstrap/state.js../../constants/xml.js../../hooks/useCanUseTool.js../../services/analytics/index.js../../utils/agentContext.js../../utils/errors.js../../utils/forkedAgent.js../../utils/frontmatterParser.js../../utils/lazySchema.js../../utils/messages.js../../utils/model/aliases.js../../utils/model/model.js../../utils/suggestions/skillUsageTracking.js../../utils/uuid.js../AgentTool/runAgent.js../utils.js./constants.js./prompt.js./UI.js../../types/tools.js../../types/tools.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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,
),
}
}