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