Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
38 kB · 1108 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109import { 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 cyclesexport 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, ), }}