Files
claude-code/src/skills/loadSkillsDir.ts
claude-code-best cd222b8e65 Fixture/flick (#1280)
* fix: 终端内容溢出 viewport 时的重影 bug

主屏幕模式下 frame 持续溢出 viewport 时,cursor-restore LF 把内容滚入 scrollback
导致相对光标追踪漂移,可见区 diff 落到错误行产生重影(重复 banner / 错位)。
扩展 log-update overflow 分支为无条件 fullReset(含 \x1b[3J 清 scrollback),
并将主屏 self-healing 清屏从 ERASE_SCREEN (CSI 2 J) 换成 ERASE_DOWN (CSI J),
避免 xterm.js / VSCode 集成终端的 scrollback 边界副作用。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除 3 个孤立诊断脚本

- scripts/verify-autofix-pr.ts: 一次性 autofix-pr 验证脚本,全仓零引用
- scripts/smoke-test-commands.ts: 开发期冒烟测试脚本,无任何 import
- scripts/probe-subscription-endpoints.ts: 手动 endpoint 探针,无引用

均不在 package.json scripts、build.ts、vite.config.ts、CI workflows 中。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 self-hosted-runner stub 及其 cli.tsx fast-path

- 删除 src/self-hosted-runner/main.ts(自动生成的 Promise.resolve() stub)
- 同步移除 src/entrypoints/cli.tsx 中 feature('SELF_HOSTED_RUNNER') 守卫的 fast-path 分支
- 该 flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码

删除 stub 单独会留下未解析的动态 import,必须协同拆除。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除 agentSdkTypes 中三个 not-implemented stub

移除 watchScheduledTasks、buildMissedTaskNotification、connectRemoteControl 三个 stub 函数(函数体仅 throw new Error('not implemented')),以及仅被这些 stub 引用的孤儿类型(ScheduledTasksHandle、ConnectRemoteControlOptions、RemoteControlHandle、InboundPrompt 等)。

全仓零外部引用。buildMissedTaskNotification 在 src/utils/cronScheduler.ts 有真实可用实现,未受影响。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 Cursor.ts 中未引用的 kill ring 访问器

- 删除 getKillRingItem、getKillRingSize、clearKillRing、canYankPop(全仓零引用的独立 export)
- 移除 VIM_WORD_CHAR_REGEX 的 export 关键字(仍由 isVimWordChar 内部使用,保留常量本体)

kill ring 特性本身仍活跃(getLastKill/pushToKillRing/yankPop 在 useSearchInput/useTextInput 使用),仅这几个孤儿 helper 未接入。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 insights.ts 中未引用的导出

- 删除 deduplicateSessionBranches(全仓零调用,含 JSDoc)
- 删除 buildExportData(全仓零调用,原 S3 上传路径实际用 HTML 而非 JSON)
- InsightsExport 仅移除 export 关键字(保留类型本体,仍作为内部返回类型)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 autonomyCommandSpec.ts 中未引用的导出

- 删除 AUTONOMY_CLI(CLI 子命令描述对象,零引用;handler 仅用 AUTONOMY_USAGE)
- 删除 AUTONOMY_COMMAND_DESCRIPTION(值已在 main.tsx:5181 内联)
- ParsedAutonomyCommand 仅移除 export 关键字(保留类型作为 parseAutonomyArgs 返回类型)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 binaryCheck/claudeAiLimits/codeIndexing 中未引用的导出

- binaryCheck.ts: 删除 clearBinaryCache(零调用,binaryCache 仍由 isBinaryInstalled 使用)
- claudeAiLimits.ts: 删除 RATE_LIMIT_DISPLAY_NAMES 常量 + getRateLimitDisplayName(互为唯一消费者)
- codeIndexing.ts: 删除 detectCodeIndexingFromMcpTool(同胞 detectCodeIndexingFromCommand/McpServerName 仍活跃)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除多处仅内部使用的 export 关键字

下列符号均仅在本文件内被引用,export 关键字冗余;保留符号本体不动:

- internalLogging.ts: getContainerId(line 88 内部调用)
- api/errors.ts: isMediaSizeError(line 151 内部调用)
- api/withRetry.ts: parseMaxTokensContextOverflowError(line 389/724 内部调用)
- statsCache.ts: STATS_CACHE_VERSION(7 处内部使用)
- startupProfiler.ts: logStartupPerf(line 128 内部调用)
- bashCommandHelpers.ts: CommandIdentityCheckers(3 处内部参数类型)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 清理注释代码块与 legacy shim

注释代码(已死的、引用不存在符号的注释块):
- Onboarding.tsx: 注释化的 preflight if-block(引用不存在的 preflightStep)
- ultraplan.tsx: 两处引用不存在符号的注释(ULTRAPLAN_INSTRUCTIONS、getUltraplanModel)
- types/hooks.ts: 禁用的 type-fest IsEqual 类型断言块
- types/global.d.ts: 已被真实模块取代的 Ultraplan ambient declares
- types/textInputTypes.ts: 注释化的 onMessage interface 成员

legacy shim:
- cli/bg.ts: 删除 handleBgFlag 别名 export(同胞 handleBgStart 已被所有调用点使用)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 ccshareResume stub 及 main.tsx 的 ccshare fast-path

- 删除 src/utils/ccshareResume.ts(parseCcshareId 恒返回 null、loadCcshare 恒抛错的 stub)
- 同步移除 src/main.tsx 中 USER_TYPE === 'ant' 守卫下的 if (ccshareId) {...} else {...} 双分支
- 提升 else 块(文件路径 resume 处理)为直接进入 if (options.resume) 块内

ccshare 是 Anthropic 内部特性(go/ccshare URL),stub 未实现导致 ccshareId 恒为 null,整个 ccshare 分支永不进入;保留的文件路径 resume 路径不变。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 environment-runner stub 及其 cli.tsx fast-path

与 self-hosted-runner 相同模式的 sibling(工作流 1 verifier 建议同步处理):

- 删除 src/environment-runner/main.ts(自动生成的 Promise.resolve() stub)
- 同步移除 src/entrypoints/cli.tsx 中 feature('BYOC_ENVIRONMENT_RUNNER') 守卫的 fast-path 分支
- 清理两个空目录(src/self-hosted-runner/、src/environment-runner/)

BYOC_ENVIRONMENT_RUNNER flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除孤立诊断脚本 probe-local-wiring.ts

#!/usr/bin/env bun shebang 的手动诊断脚本,全仓零引用,不在 package.json/build.ts/vite.config.ts/CI workflows 中。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 ultrareview preflight stub 及其测试

- 删除 src/services/api/ultrareviewPreflight.ts(自动生成的 stub)
- 删除 src/commands/review/UltrareviewPreflightDialog.tsx(依赖前者的 UI stub)
- 删除 src/services/api/__tests__/ultrareviewPreflight.test.ts(测试已删代码)
- 同步移除 ultrareviewCommand.test.tsx 中对 UltrareviewPreflightDialog 的 mock

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 cachedMCConfig stub 及 prompts.ts 的 CACHED_MICROCOMPACT 死代码

- 删除 src/services/compact/cachedMCConfig.ts(自动生成的 stub)
- 同步移除 src/constants/prompts.ts 中依赖该 stub 的代码:
  - getCachedMCConfigForFRC 变量(feature('CACHED_MICROCOMPACT') 守卫的 require)
  - getFunctionResultClearingSection 函数(约 18 行)
  - systemPrompt 数组中的 frc section 调用与注册

CACHED_MICROCOMPACT 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 goalAudit stub 及其测试引用

- 删除 src/services/goal/goalAudit.ts(导出 COMPLETION_AUDIT_RULES/BLOCKED_AUDIT_RULES/isGoalTerminal 等未引用的 stub)
- 同步移除 tests/integration/goal-lifecycle.test.ts 中对 goalAudit 的 import 和一个测试用例(budget_limited is terminal)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 删除 agentSdkTypes 第二批 not-implemented stub

移除运行时函数体仅为 throw new Error 或 placeholder 的 stub:
- createSdkMcpToolDefinition、createSdkMcpServer
- query 函数重载与实现
- unstable_v2_* 系列函数
- session 操作 stub(getSessionMessages/listSessions/getSessionInfo/renameSession/tagSession/forkSession)
- AbortError 类

保留所有 export type 重导出和类型别名(仍是公共类型面)。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 Tool.ts 中 backwards-compat 重导出 shim

删除 "// Re-export progress types for backwards compatibility" 注释块及其重导出语句。所有消费方已直接从 src/types/tools.js 导入,无需重导出转发。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 bootstrap/state.ts 中 4 个未引用的 export

- clearRegisteredHooks(STATE.registeredHooks 仍由其他函数管理)
- getInvokedSkills(getInvokedSkillsForAgent 是活跃入口)
- getSessionSource(setSessionSource 仍活跃,sessionSource state 字段保留)
- markScrollActivity(scrollDraining/getIsScrollDraining/waitForScrollDrain 仍活跃)

仅删除孤儿访问器,不动模块级 state 副作用。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 src/ 下多处未引用的导出

涉及 18 个文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除:

- bridge/bridgeStatusUtil.ts、components/TrustDialog/utils.ts、context/stats.tsx
- keybindings/loadUserBindings.ts、memdir/paths.ts、remote/sdkMessageAdapter.ts
- services/acp/utils.ts(删除 nodeToWebReadable,全仓零引用)
- services/api/metricsOptOut.ts、services/lsp/LSPDiagnosticRegistry.ts、services/lsp/manager.ts
- services/mcp/utils.ts、services/skillLearning/projectContext.ts
- services/teamMemorySync/secretScanner.ts、services/teamMemorySync/watcher.ts
- skills/loadSkillsDir.ts、utils/attachments.ts、utils/filePersistence/filePersistence.ts
- utils/messageQueueManager.ts

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore: 移除 packages/ 下多处未引用的导出

涉及 11 个 workspace 包文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除:

- @ant/ink/core/termio/csi.ts(eraseLine)
- acp-link/manager/types.ts、acp-link/ws-message.ts
- builtin-tools/AgentTool/agentMemory.ts、BashTool/bashSecurity.ts、BashTool/sedEditParser.ts
- builtin-tools/ConfigTool/supportedSettings.ts、FileEditTool/utils.ts
- remote-control-server/store.ts、transport/event-bus.ts、types/messages.ts

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* Revert "fix: 终端内容溢出 viewport 时的重影 bug"

This reverts commit 3d18e1da58.

* revert: 移除主屏幕周期性 self-healing 重绘

回退 f69c7051 中引入的 ink.tsx self-healing 机制(lastMainScreenHealTime 字段
+ 每 5 秒触发全量重绘 + needsEraseBeforePaint 主屏幕分支)。该机制在 workflow
面板持续刷新场景下表现为可见的"重复刷新",且修复效果不稳定。

alt-screen 的 needsEraseBeforePaint 路径和 prevFrameContaminated 字段保留,
它们仍服务于 handleResize / layout shift / selection 高亮。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix: /workflows 面板默认只显示运行中 run,根治 tab 行乱码

之前几次渲染层修复都失败,因为没动 tab 列表的数据源:打开 /workflows 会
自动 hydrate 最多 20 个历史 done/killed run,全部塞进一行 TabsBar,超出
终端宽度后 Ink 把字符画到屏外造成重影乱码。

- selectors.ts 加 filterActiveRuns(只留 status === 'running')和
  capTabsForDisplay(超额 fold 成 +N)两个 pure function
- WorkflowsPanel 接线 activeRuns:focus clamp、focused、nextTab/prevTab、
  TabsBar 全部基于过滤后的 activeRuns
- TabsBar 复用 truncateLabel 限制每个 tab 名 18 字符 + 最多 6 个 tab,
  多余显示 +N,从结构上钉死单行总宽度

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix: /workflows 面板 phase 状态在脚本省略 phase() 时显示错乱

ultracode canonical pipeline 脚本常在 agent() 直接传 opts.phase 而不调 phase()
hook,导致 phase_started 从未发出;同时 phase_done 只在下次 phase() 触发,上一
个 phase 在 run.phases 里一直停在 running。mergePhases 之前把 actual 当权威,
于是出现 "Map 8/8 全 done 还显示 running、Find 1/4 running 反而显示 pending"。

改为派生层修复:mergePhases 新增 derivePhaseStatus——actual.status==='done'
权威;否则有 agents 就按 agents 状态推(全 done→done,否则 running);否则看
actual 是否 running。再补一层遍历,让只在 agents 上出现的 phase 也进 sidebar。
不改 store 状态语义,已有 state.json 无需迁移。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs: 更新 readme

* fix: ACP 模式未读取 settings.local.json

entry.ts 在 ACP 握手期调用的 applySafeConfigEnvironmentVariables 触发了
loadSettingsFromDisk,此时 getOriginalCwd() 还是进程启动 cwd(非项目目录),
导致 localSettings/projectSettings 按错误路径解析为空并被 session cache 锁住,
后续 createSession 里 setOriginalCwd 也无法纠正。在 setOriginalCwd 与 chdir
之后清缓存并重新应用,让 settings.local.json 和项目级 env 对
readSettingsPermissionMode 及下游可见。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

---------

Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
2026-06-22 09:59:36 +08:00

1080 lines
33 KiB
TypeScript

import { realpath } from 'fs/promises'
import ignore from 'ignore'
import memoize from 'lodash-es/memoize.js'
import {
basename,
dirname,
isAbsolute,
join,
sep as pathSep,
relative,
} from 'path'
import {
getAdditionalDirectoriesForClaudeMd,
getSessionId,
} from '../bootstrap/state.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../services/analytics/index.js'
import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
import type { Command, PromptCommand } from '../types/command.js'
import {
parseArgumentNames,
substituteArguments,
} from '../utils/argumentSubstitution.js'
import { logForDebugging } from '../utils/debug.js'
import {
EFFORT_LEVELS,
type EffortValue,
parseEffortValue,
} from '../utils/effort.js'
import {
getClaudeConfigHomeDir,
isBareMode,
isEnvTruthy,
} from '../utils/envUtils.js'
import { isENOENT, isFsInaccessible } from '../utils/errors.js'
import {
coerceDescriptionToString,
type FrontmatterData,
type FrontmatterShell,
parseBooleanFrontmatter,
parseFrontmatter,
parseShellFrontmatter,
splitPathInFrontmatter,
} from '../utils/frontmatterParser.js'
import { getFsImplementation } from '../utils/fsOperations.js'
import { isPathGitignored } from '../utils/git/gitignore.js'
import { logError } from '../utils/log.js'
import {
extractDescriptionFromMarkdown,
getProjectDirsUpToHome,
loadMarkdownFilesForSubdir,
type MarkdownFile,
parseSlashCommandToolsFromFrontmatter,
} from '../utils/markdownConfigLoader.js'
import { parseUserSpecifiedModel } from '../utils/model/model.js'
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
import type { SettingSource } from '../utils/settings/constants.js'
import { isSettingSourceEnabled } from '../utils/settings/constants.js'
import { getManagedFilePath } from '../utils/settings/managedPath.js'
import { isRestrictedToPluginOnly } from '../utils/settings/pluginOnlyPolicy.js'
import { HooksSchema, type HooksSettings } from '../utils/settings/types.js'
import { createSignal } from '../utils/signal.js'
import { registerMCPSkillBuilders } from './mcpSkillBuilders.js'
export type LoadedFrom =
| 'commands_DEPRECATED'
| 'skills'
| 'plugin'
| 'managed'
| 'bundled'
| 'mcp'
/**
* Returns a claude config directory path for a given source.
*/
export function getSkillsPath(
source: SettingSource | 'plugin',
dir: 'skills' | 'commands',
): string {
switch (source) {
case 'policySettings':
return join(getManagedFilePath(), '.claude', dir)
case 'userSettings':
return join(getClaudeConfigHomeDir(), dir)
case 'projectSettings':
return `.claude/${dir}`
case 'plugin':
return 'plugin'
default:
return ''
}
}
/**
* Estimates token count for a skill based on frontmatter only
* (name, description, whenToUse) since full content is only loaded on invocation.
*/
export function estimateSkillFrontmatterTokens(skill: Command): number {
const frontmatterText = [skill.name, skill.description, skill.whenToUse]
.filter(Boolean)
.join(' ')
return roughTokenCountEstimation(frontmatterText)
}
/**
* Gets a unique identifier for a file by resolving symlinks to a canonical path.
* This allows detection of duplicate files accessed through different paths
* (e.g., via symlinks or overlapping parent directories).
* Returns null if the file doesn't exist or can't be resolved.
*
* Uses realpath to resolve symlinks, which is filesystem-agnostic and avoids
* issues with filesystems that report unreliable inode values (e.g., inode 0 on
* some virtual/container/NFS filesystems, or precision loss on ExFAT).
* See: https://github.com/anthropics/claude-code/issues/13893
*/
async function getFileIdentity(filePath: string): Promise<string | null> {
try {
return await realpath(filePath)
} catch {
return null
}
}
// Internal type to track skill with its file path for deduplication
type SkillWithPath = {
skill: Command
filePath: string
}
/**
* Parse and validate hooks from frontmatter.
* Returns undefined if hooks are not defined or invalid.
*/
function parseHooksFromFrontmatter(
frontmatter: FrontmatterData,
skillName: string,
): HooksSettings | undefined {
if (!frontmatter.hooks) {
return undefined
}
const result = HooksSchema().safeParse(frontmatter.hooks)
if (!result.success) {
logForDebugging(
`Invalid hooks in skill '${skillName}': ${result.error.message}`,
)
return undefined
}
return result.data
}
/**
* Parse paths frontmatter from a skill, using the same format as CLAUDE.md rules.
* Returns undefined if no paths are specified or if all patterns are match-all.
*/
function parseSkillPaths(frontmatter: FrontmatterData): string[] | undefined {
if (!frontmatter.paths) {
return undefined
}
const patterns = splitPathInFrontmatter(frontmatter.paths)
.map(pattern => {
// Remove /** suffix - ignore library treats 'path' as matching both
// the path itself and everything inside it
return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern
})
.filter((p: string) => p.length > 0)
// If all patterns are ** (match-all), treat as no paths (undefined)
if (patterns.length === 0 || patterns.every((p: string) => p === '**')) {
return undefined
}
return patterns
}
/**
* Parses all skill frontmatter fields that are shared between file-based and
* MCP skill loading. Caller supplies the resolved skill name and the
* source/loadedFrom/baseDir/paths fields separately.
*/
export function parseSkillFrontmatterFields(
frontmatter: FrontmatterData,
markdownContent: string,
resolvedName: string,
descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill',
): {
displayName: string | undefined
description: string
hasUserSpecifiedDescription: boolean
allowedTools: string[]
argumentHint: string | undefined
argumentNames: string[]
whenToUse: string | undefined
version: string | undefined
model: ReturnType<typeof parseUserSpecifiedModel> | undefined
disableModelInvocation: boolean
userInvocable: boolean
hooks: HooksSettings | undefined
executionContext: 'fork' | undefined
agent: string | undefined
effort: EffortValue | undefined
shell: FrontmatterShell | undefined
} {
const validatedDescription = coerceDescriptionToString(
frontmatter.description,
resolvedName,
)
const description =
validatedDescription ??
extractDescriptionFromMarkdown(markdownContent, descriptionFallbackLabel)
const userInvocable =
frontmatter['user-invocable'] === undefined
? true
: parseBooleanFrontmatter(frontmatter['user-invocable'])
const model =
frontmatter.model === 'inherit'
? undefined
: frontmatter.model
? parseUserSpecifiedModel(frontmatter.model as string)
: undefined
const effortRaw = frontmatter['effort']
const effort =
effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
if (effortRaw !== undefined && effort === undefined) {
logForDebugging(
`Skill ${resolvedName} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
)
}
return {
displayName:
frontmatter.name != null ? String(frontmatter.name) : undefined,
description,
hasUserSpecifiedDescription: validatedDescription !== null,
allowedTools: parseSlashCommandToolsFromFrontmatter(
frontmatter['allowed-tools'],
),
argumentHint:
frontmatter['argument-hint'] != null
? String(frontmatter['argument-hint'])
: undefined,
argumentNames: parseArgumentNames(
frontmatter.arguments as string | string[] | undefined,
),
whenToUse: frontmatter.when_to_use as string | undefined,
version: frontmatter.version as string | undefined,
model,
disableModelInvocation: parseBooleanFrontmatter(
frontmatter['disable-model-invocation'],
),
userInvocable,
hooks: parseHooksFromFrontmatter(frontmatter, resolvedName),
executionContext: frontmatter.context === 'fork' ? 'fork' : undefined,
agent: frontmatter.agent as string | undefined,
effort,
shell: parseShellFrontmatter(frontmatter.shell, resolvedName),
}
}
/**
* Creates a skill command from parsed data
*/
export function createSkillCommand({
skillName,
displayName,
description,
hasUserSpecifiedDescription,
markdownContent,
allowedTools,
argumentHint,
argumentNames,
whenToUse,
version,
model,
disableModelInvocation,
userInvocable,
source,
baseDir,
loadedFrom,
hooks,
executionContext,
agent,
paths,
effort,
shell,
}: {
skillName: string
displayName: string | undefined
description: string
hasUserSpecifiedDescription: boolean
markdownContent: string
allowedTools: string[]
argumentHint: string | undefined
argumentNames: string[]
whenToUse: string | undefined
version: string | undefined
model: string | undefined
disableModelInvocation: boolean
userInvocable: boolean
source: PromptCommand['source']
baseDir: string | undefined
loadedFrom: LoadedFrom
hooks: HooksSettings | undefined
executionContext: 'inline' | 'fork' | undefined
agent: string | undefined
paths: string[] | undefined
effort: EffortValue | undefined
shell: FrontmatterShell | undefined
}): Command {
return {
type: 'prompt',
name: skillName,
description,
hasUserSpecifiedDescription,
allowedTools,
argumentHint,
argNames: argumentNames.length > 0 ? argumentNames : undefined,
whenToUse,
version,
model,
disableModelInvocation,
userInvocable,
context: executionContext,
agent,
effort,
paths,
contentLength: markdownContent.length,
isHidden: !userInvocable,
progressMessage: 'running',
userFacingName(): string {
return displayName || skillName
},
source,
loadedFrom,
hooks,
skillRoot: baseDir,
async getPromptForCommand(args, toolUseContext) {
let finalContent = baseDir
? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
: markdownContent
finalContent = substituteArguments(
finalContent,
args,
true,
argumentNames,
)
// Replace ${CLAUDE_SKILL_DIR} with the skill's own directory so bash
// injection (!`...`) can reference bundled scripts. Normalize backslashes
// to forward slashes on Windows so shell commands don't treat them as escapes.
if (baseDir) {
const skillDir =
process.platform === 'win32' ? baseDir.replace(/\\/g, '/') : baseDir
finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir)
}
// Replace ${CLAUDE_SESSION_ID} with the current session ID
finalContent = finalContent.replace(
/\$\{CLAUDE_SESSION_ID\}/g,
getSessionId(),
)
// Security: MCP skills are remote and untrusted — never execute inline
// shell commands (!`…` / ```! … ```) from their markdown body.
// ${CLAUDE_SKILL_DIR} is meaningless for MCP skills anyway.
if (loadedFrom !== 'mcp') {
finalContent = await executeShellCommandsInPrompt(
finalContent,
{
...toolUseContext,
getAppState() {
const appState = toolUseContext.getAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: allowedTools,
},
},
}
},
},
`/${skillName}`,
shell,
)
}
return [{ type: 'text', text: finalContent }]
},
} satisfies Command
}
/**
* Loads skills from a /skills/ directory path.
* Only supports directory format: skill-name/SKILL.md
*/
async function loadSkillsFromSkillsDir(
basePath: string,
source: SettingSource,
): Promise<SkillWithPath[]> {
const fs = getFsImplementation()
let entries
try {
entries = await fs.readdir(basePath)
} catch (e: unknown) {
if (!isFsInaccessible(e)) logError(e)
return []
}
const results = await Promise.all(
entries.map(async (entry): Promise<SkillWithPath | null> => {
try {
// Only support directory format: skill-name/SKILL.md
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
// Single .md files are NOT supported in /skills/ directory
return null
}
const skillDirPath = join(basePath, entry.name)
const skillFilePath = join(skillDirPath, 'SKILL.md')
let content: string
try {
content = await fs.readFile(skillFilePath, { encoding: 'utf-8' })
} catch (e: unknown) {
// SKILL.md doesn't exist, skip this entry. Log non-ENOENT errors
// (EACCES/EPERM/EIO) so permission/IO problems are diagnosable.
if (!isENOENT(e)) {
logForDebugging(`[skills] failed to read ${skillFilePath}: ${e}`, {
level: 'warn',
})
}
return null
}
const { frontmatter, content: markdownContent } = parseFrontmatter(
content,
skillFilePath,
)
const skillName = entry.name
const parsed = parseSkillFrontmatterFields(
frontmatter,
markdownContent,
skillName,
)
const paths = parseSkillPaths(frontmatter)
return {
skill: createSkillCommand({
...parsed,
skillName,
markdownContent,
source,
baseDir: skillDirPath,
loadedFrom: 'skills',
paths,
}),
filePath: skillFilePath,
}
} catch (error) {
logError(error)
return null
}
}),
)
return results.filter((r): r is SkillWithPath => r !== null)
}
// --- Legacy /commands/ loader ---
function isSkillFile(filePath: string): boolean {
return /^skill\.md$/i.test(basename(filePath))
}
/**
* Transforms markdown files to handle "skill" commands in legacy /commands/ folder.
* When a SKILL.md file exists in a directory, only that file is loaded
* and it takes the name of its parent directory.
*/
function transformSkillFiles(files: MarkdownFile[]): MarkdownFile[] {
const filesByDir = new Map<string, MarkdownFile[]>()
for (const file of files) {
const dir = dirname(file.filePath)
const dirFiles = filesByDir.get(dir) ?? []
dirFiles.push(file)
filesByDir.set(dir, dirFiles)
}
const result: MarkdownFile[] = []
for (const [dir, dirFiles] of filesByDir) {
const skillFiles = dirFiles.filter(f => isSkillFile(f.filePath))
if (skillFiles.length > 0) {
const skillFile = skillFiles[0]!
if (skillFiles.length > 1) {
logForDebugging(
`Multiple skill files found in ${dir}, using ${basename(skillFile.filePath)}`,
)
}
result.push(skillFile)
} else {
result.push(...dirFiles)
}
}
return result
}
function buildNamespace(targetDir: string, baseDir: string): string {
const normalizedBaseDir = baseDir.endsWith(pathSep)
? baseDir.slice(0, -1)
: baseDir
if (targetDir === normalizedBaseDir) {
return ''
}
const relativePath = targetDir.slice(normalizedBaseDir.length + 1)
return relativePath ? relativePath.split(pathSep).join(':') : ''
}
function getSkillCommandName(filePath: string, baseDir: string): string {
const skillDirectory = dirname(filePath)
const parentOfSkillDir = dirname(skillDirectory)
const commandBaseName = basename(skillDirectory)
const namespace = buildNamespace(parentOfSkillDir, baseDir)
return namespace ? `${namespace}:${commandBaseName}` : commandBaseName
}
function getRegularCommandName(filePath: string, baseDir: string): string {
const fileName = basename(filePath)
const fileDirectory = dirname(filePath)
const commandBaseName = fileName.replace(/\.md$/, '')
const namespace = buildNamespace(fileDirectory, baseDir)
return namespace ? `${namespace}:${commandBaseName}` : commandBaseName
}
function getCommandName(file: MarkdownFile): string {
const isSkill = isSkillFile(file.filePath)
return isSkill
? getSkillCommandName(file.filePath, file.baseDir)
: getRegularCommandName(file.filePath, file.baseDir)
}
/**
* Loads skills from legacy /commands/ directories.
* Supports both directory format (SKILL.md) and single .md file format.
* Commands from /commands/ default to user-invocable: true
*/
async function loadSkillsFromCommandsDir(
cwd: string,
): Promise<SkillWithPath[]> {
try {
const markdownFiles = await loadMarkdownFilesForSubdir('commands', cwd)
const processedFiles = transformSkillFiles(markdownFiles)
const skills: SkillWithPath[] = []
for (const {
baseDir,
filePath,
frontmatter,
content,
source,
} of processedFiles) {
try {
const isSkillFormat = isSkillFile(filePath)
const skillDirectory = isSkillFormat ? dirname(filePath) : undefined
const cmdName = getCommandName({
baseDir,
filePath,
frontmatter,
content,
source,
})
const parsed = parseSkillFrontmatterFields(
frontmatter,
content,
cmdName,
'Custom command',
)
skills.push({
skill: createSkillCommand({
...parsed,
skillName: cmdName,
displayName: undefined,
markdownContent: content,
source,
baseDir: skillDirectory,
loadedFrom: 'commands_DEPRECATED',
paths: undefined,
}),
filePath,
})
} catch (error) {
logError(error)
}
}
return skills
} catch (error) {
logError(error)
return []
}
}
/**
* Loads all skills from both /skills/ and legacy /commands/ directories.
*
* Skills from /skills/ directories:
* - Only support directory format: skill-name/SKILL.md
* - Default to user-invocable: true (can opt-out with user-invocable: false)
*
* Skills from legacy /commands/ directories:
* - Support both directory format (SKILL.md) and single .md file format
* - Default to user-invocable: true (user can type /cmd)
*
* @param cwd Current working directory for project directory traversal
*/
export const getSkillDirCommands = memoize(
async (cwd: string): Promise<Command[]> => {
const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills')
const managedSkillsDir = join(getManagedFilePath(), '.claude', 'skills')
const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd)
logForDebugging(
`Loading skills from: managed=${managedSkillsDir}, user=${userSkillsDir}, project=[${projectSkillsDirs.join(', ')}]`,
)
// Load from additional directories (--add-dir)
const additionalDirs = getAdditionalDirectoriesForClaudeMd()
const skillsLocked = isRestrictedToPluginOnly('skills')
const projectSettingsEnabled =
isSettingSourceEnabled('projectSettings') && !skillsLocked
// --bare: skip auto-discovery (managed/user/project dir walks + legacy
// commands-dir). Load ONLY explicit --add-dir paths. Bundled skills
// register separately. skillsLocked still applies — --bare is not a
// policy bypass.
if (isBareMode()) {
if (additionalDirs.length === 0 || !projectSettingsEnabled) {
logForDebugging(
`[bare] Skipping skill dir discovery (${additionalDirs.length === 0 ? 'no --add-dir' : 'projectSettings disabled or skillsLocked'})`,
)
return []
}
const additionalSkillsNested = await Promise.all(
additionalDirs.map(dir =>
loadSkillsFromSkillsDir(
join(dir, '.claude', 'skills'),
'projectSettings',
),
),
)
// No dedup needed — explicit dirs, user controls uniqueness.
return additionalSkillsNested.flat().map(s => s.skill)
}
// Load from /skills/ directories, additional dirs, and legacy /commands/ in parallel
// (all independent — different directories, no shared state)
const [
managedSkills,
userSkills,
projectSkillsNested,
additionalSkillsNested,
legacyCommands,
] = await Promise.all([
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS)
? Promise.resolve([])
: loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
isSettingSourceEnabled('userSettings') && !skillsLocked
? loadSkillsFromSkillsDir(userSkillsDir, 'userSettings')
: Promise.resolve([]),
projectSettingsEnabled
? Promise.all(
projectSkillsDirs.map(dir =>
loadSkillsFromSkillsDir(dir, 'projectSettings'),
),
)
: Promise.resolve([]),
projectSettingsEnabled
? Promise.all(
additionalDirs.map(dir =>
loadSkillsFromSkillsDir(
join(dir, '.claude', 'skills'),
'projectSettings',
),
),
)
: Promise.resolve([]),
// Legacy commands-as-skills goes through markdownConfigLoader with
// subdir='commands', which our agents-only guard there skips. Block
// here when skills are locked — these ARE skills, regardless of the
// directory they load from.
skillsLocked ? Promise.resolve([]) : loadSkillsFromCommandsDir(cwd),
])
// Flatten and combine all skills
const allSkillsWithPaths = [
...managedSkills,
...userSkills,
...projectSkillsNested.flat(),
...additionalSkillsNested.flat(),
...legacyCommands,
]
// Deduplicate by resolved path (handles symlinks and duplicate parent directories)
// Pre-compute file identities in parallel (realpath calls are independent),
// then dedup synchronously (order-dependent first-wins)
const fileIds = await Promise.all(
allSkillsWithPaths.map(({ skill, filePath }) =>
skill.type === 'prompt'
? getFileIdentity(filePath)
: Promise.resolve(null),
),
)
const seenFileIds = new Map<
string,
SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'
>()
const deduplicatedSkills: Command[] = []
for (let i = 0; i < allSkillsWithPaths.length; i++) {
const entry = allSkillsWithPaths[i]
if (entry === undefined || entry.skill.type !== 'prompt') continue
const { skill } = entry
const fileId = fileIds[i]
if (fileId === null || fileId === undefined) {
deduplicatedSkills.push(skill)
continue
}
const existingSource = seenFileIds.get(fileId)
if (existingSource !== undefined) {
logForDebugging(
`Skipping duplicate skill '${skill.name}' from ${skill.source} (same file already loaded from ${existingSource})`,
)
continue
}
seenFileIds.set(fileId, skill.source)
deduplicatedSkills.push(skill)
}
const duplicatesRemoved =
allSkillsWithPaths.length - deduplicatedSkills.length
if (duplicatesRemoved > 0) {
logForDebugging(`Deduplicated ${duplicatesRemoved} skills (same file)`)
}
// Separate conditional skills (with paths frontmatter) from unconditional ones
const unconditionalSkills: Command[] = []
const newConditionalSkills: Command[] = []
for (const skill of deduplicatedSkills) {
if (
skill.type === 'prompt' &&
skill.paths &&
skill.paths.length > 0 &&
!activatedConditionalSkillNames.has(skill.name)
) {
newConditionalSkills.push(skill)
} else {
unconditionalSkills.push(skill)
}
}
// Store conditional skills for later activation when matching files are touched
for (const skill of newConditionalSkills) {
conditionalSkills.set(skill.name, skill)
}
if (newConditionalSkills.length > 0) {
logForDebugging(
`[skills] ${newConditionalSkills.length} conditional skills stored (activated when matching files are touched)`,
)
}
logForDebugging(
`Loaded ${deduplicatedSkills.length} unique skills (${unconditionalSkills.length} unconditional, ${newConditionalSkills.length} conditional, managed: ${managedSkills.length}, user: ${userSkills.length}, project: ${projectSkillsNested.flat().length}, additional: ${additionalSkillsNested.flat().length}, legacy commands: ${legacyCommands.length})`,
)
return unconditionalSkills
},
)
export function clearSkillCaches() {
getSkillDirCommands.cache?.clear?.()
loadMarkdownFilesForSubdir.cache?.clear?.()
conditionalSkills.clear()
activatedConditionalSkillNames.clear()
}
// Backwards-compatible aliases for tests
export { getSkillDirCommands as getCommandDirCommands }
export { clearSkillCaches as clearCommandCaches }
export { transformSkillFiles }
// --- Dynamic skill discovery ---
// State for dynamically discovered skills
const dynamicSkillDirs = new Set<string>()
const dynamicSkills = new Map<string, Command>()
// --- Conditional skills (path-filtered) ---
// Skills with paths frontmatter that haven't been activated yet
const conditionalSkills = new Map<string, Command>()
// Names of skills that have been activated (survives cache clears within a session)
const activatedConditionalSkillNames = new Set<string>()
// Signal fired when dynamic skills are loaded
const skillsLoaded = createSignal()
/**
* Register a callback to be invoked when dynamic skills are loaded.
* Used by other modules to clear caches without creating import cycles.
* Returns an unsubscribe function.
*/
export function onDynamicSkillsLoaded(callback: () => void): () => void {
// Wrap at subscribe time so a throwing listener is logged and skipped
// rather than aborting skillsLoaded.emit() and breaking skill loading.
// Same callSafe pattern as growthbook.ts — createSignal.emit() has no
// per-listener try/catch.
return skillsLoaded.subscribe(() => {
try {
callback()
} catch (error) {
logError(error)
}
})
}
/**
* Discovers skill directories by walking up from file paths to cwd.
* Only discovers directories below cwd (cwd-level skills are loaded at startup).
*
* @param filePaths Array of file paths to check
* @param cwd Current working directory (upper bound for discovery)
* @returns Array of newly discovered skill directories, sorted deepest first
*/
export async function discoverSkillDirsForPaths(
filePaths: string[],
cwd: string,
): Promise<string[]> {
const fs = getFsImplementation()
const resolvedCwd = cwd.endsWith(pathSep) ? cwd.slice(0, -1) : cwd
const newDirs: string[] = []
for (const filePath of filePaths) {
// Start from the file's parent directory
let currentDir = dirname(filePath)
// Walk up to cwd but NOT including cwd itself
// CWD-level skills are already loaded at startup, so we only discover nested ones
// Use prefix+separator check to avoid matching /project-backup when cwd is /project
while (currentDir.startsWith(resolvedCwd + pathSep)) {
const skillDir = join(currentDir, '.claude', 'skills')
// Skip if we've already checked this path (hit or miss) — avoids
// repeating the same failed stat on every Read/Write/Edit call when
// the directory doesn't exist (the common case).
if (!dynamicSkillDirs.has(skillDir)) {
dynamicSkillDirs.add(skillDir)
try {
await fs.stat(skillDir)
// Skills dir exists. Before loading, check if the containing dir
// is gitignored — blocks e.g. node_modules/pkg/.claude/skills from
// loading silently. `git check-ignore` handles nested .gitignore,
// .git/info/exclude, and global gitignore. Fails open outside a
// git repo (exit 128 → false); the invocation-time trust dialog
// is the actual security boundary.
if (await isPathGitignored(currentDir, resolvedCwd)) {
logForDebugging(
`[skills] Skipped gitignored skills dir: ${skillDir}`,
)
continue
}
newDirs.push(skillDir)
} catch {
// Directory doesn't exist — already recorded above, continue
}
}
// Move to parent
const parent = dirname(currentDir)
if (parent === currentDir) break // Reached root
currentDir = parent
}
}
// Sort by path depth (deepest first) so skills closer to the file take precedence
return newDirs.sort(
(a, b) => b.split(pathSep).length - a.split(pathSep).length,
)
}
/**
* Loads skills from the given directories and merges them into the dynamic skills map.
* Skills from directories closer to the file (deeper paths) take precedence.
*
* @param dirs Array of skill directories to load from (should be sorted deepest first)
*/
export async function addSkillDirectories(dirs: string[]): Promise<void> {
if (
!isSettingSourceEnabled('projectSettings') ||
isRestrictedToPluginOnly('skills')
) {
logForDebugging(
'[skills] Dynamic skill discovery skipped: projectSettings disabled or plugin-only policy',
)
return
}
if (dirs.length === 0) {
return
}
const previousSkillNamesForLogging = new Set(dynamicSkills.keys())
// Load skills from all directories
const loadedSkills = await Promise.all(
dirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings')),
)
// Process in reverse order (shallower first) so deeper paths override
for (let i = loadedSkills.length - 1; i >= 0; i--) {
for (const { skill } of loadedSkills[i] ?? []) {
if (skill.type === 'prompt') {
dynamicSkills.set(skill.name, skill)
}
}
}
const newSkillCount = loadedSkills.flat().length
if (newSkillCount > 0) {
const addedSkills = [...dynamicSkills.keys()].filter(
n => !previousSkillNamesForLogging.has(n),
)
logForDebugging(
`[skills] Dynamically discovered ${newSkillCount} skills from ${dirs.length} directories`,
)
if (addedSkills.length > 0) {
logEvent('tengu_dynamic_skills_changed', {
source:
'file_operation' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
previousCount: previousSkillNamesForLogging.size,
newCount: dynamicSkills.size,
addedCount: addedSkills.length,
directoryCount: dirs.length,
})
}
}
// Notify listeners that skills were loaded (so they can clear caches)
skillsLoaded.emit()
}
/**
* Gets all dynamically discovered skills.
* These are skills discovered from file paths during the session.
*/
export function getDynamicSkills(): Command[] {
return Array.from(dynamicSkills.values())
}
/**
* Activates conditional skills (skills with paths frontmatter) whose path
* patterns match the given file paths. Activated skills are added to the
* dynamic skills map, making them available to the model.
*
* Uses the `ignore` library (gitignore-style matching), matching the behavior
* of CLAUDE.md conditional rules.
*
* @param filePaths Array of file paths being operated on
* @param cwd Current working directory (paths are matched relative to cwd)
* @returns Array of newly activated skill names
*/
export function activateConditionalSkillsForPaths(
filePaths: string[],
cwd: string,
): string[] {
if (conditionalSkills.size === 0) {
return []
}
const activated: string[] = []
for (const [name, skill] of conditionalSkills) {
if (skill.type !== 'prompt' || !skill.paths || skill.paths.length === 0) {
continue
}
const skillIgnore = ignore().add(skill.paths)
for (const filePath of filePaths) {
const relativePath = isAbsolute(filePath)
? relative(cwd, filePath)
: filePath
// ignore() throws on empty strings, paths escaping the base (../),
// and absolute paths (Windows cross-drive relative() returns absolute).
// Files outside cwd can't match cwd-relative patterns anyway.
if (
!relativePath ||
relativePath.startsWith('..') ||
isAbsolute(relativePath)
) {
continue
}
if (skillIgnore.ignores(relativePath)) {
// Activate this skill by moving it to dynamic skills
dynamicSkills.set(name, skill)
conditionalSkills.delete(name)
activatedConditionalSkillNames.add(name)
activated.push(name)
logForDebugging(
`[skills] Activated conditional skill '${name}' (matched path: ${relativePath})`,
)
break
}
}
}
if (activated.length > 0) {
logEvent('tengu_dynamic_skills_changed', {
source:
'conditional_paths' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
previousCount: dynamicSkills.size - activated.length,
newCount: dynamicSkills.size,
addedCount: activated.length,
directoryCount: 0,
})
// Notify listeners that skills were loaded (so they can clear caches)
skillsLoaded.emit()
}
return activated
}
/**
* Clears dynamic skill state (for testing).
*/
export function clearDynamicSkills(): void {
dynamicSkillDirs.clear()
dynamicSkills.clear()
conditionalSkills.clear()
activatedConditionalSkillNames.clear()
}
// Expose createSkillCommand + parseSkillFrontmatterFields to MCP skill
// discovery via a leaf registry module. See mcpSkillBuilders.ts for why this
// indirection exists (a literal dynamic import from mcpSkills.ts fans a single
// edge out into many cycle violations; a variable-specifier dynamic import
// passes dep-cruiser but fails to resolve in Bun-bundled binaries at runtime).
// eslint-disable-next-line custom-rules/no-top-level-side-effects -- write-once registration, idempotent
registerMCPSkillBuilders({
createSkillCommand,
parseSkillFrontmatterFields,
})