commands.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
  1. // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
  2. import addDir from './commands/add-dir/index.js'
  3. import autofixPr from './commands/autofix-pr/index.js'
  4. import backfillSessions from './commands/backfill-sessions/index.js'
  5. import btw from './commands/btw/index.js'
  6. import goodClaude from './commands/good-claude/index.js'
  7. import issue from './commands/issue/index.js'
  8. import feedback from './commands/feedback/index.js'
  9. import clear from './commands/clear/index.js'
  10. import color from './commands/color/index.js'
  11. import commit from './commands/commit.js'
  12. import copy from './commands/copy/index.js'
  13. import desktop from './commands/desktop/index.js'
  14. import commitPushPr from './commands/commit-push-pr.js'
  15. import compact from './commands/compact/index.js'
  16. import config from './commands/config/index.js'
  17. import { context, contextNonInteractive } from './commands/context/index.js'
  18. import cost from './commands/cost/index.js'
  19. import diff from './commands/diff/index.js'
  20. import ctx_viz from './commands/ctx_viz/index.js'
  21. import doctor from './commands/doctor/index.js'
  22. import memory from './commands/memory/index.js'
  23. import help from './commands/help/index.js'
  24. import ide from './commands/ide/index.js'
  25. import init from './commands/init.js'
  26. import initVerifiers from './commands/init-verifiers.js'
  27. import keybindings from './commands/keybindings/index.js'
  28. import login from './commands/login/index.js'
  29. import logout from './commands/logout/index.js'
  30. import installGitHubApp from './commands/install-github-app/index.js'
  31. import installSlackApp from './commands/install-slack-app/index.js'
  32. import breakCache from './commands/break-cache/index.js'
  33. import mcp from './commands/mcp/index.js'
  34. import mobile from './commands/mobile/index.js'
  35. import onboarding from './commands/onboarding/index.js'
  36. import pr_comments from './commands/pr_comments/index.js'
  37. import releaseNotes from './commands/release-notes/index.js'
  38. import rename from './commands/rename/index.js'
  39. import resume from './commands/resume/index.js'
  40. import review, { ultrareview } from './commands/review.js'
  41. import session from './commands/session/index.js'
  42. import share from './commands/share/index.js'
  43. import skills from './commands/skills/index.js'
  44. import status from './commands/status/index.js'
  45. import tasks from './commands/tasks/index.js'
  46. import teleport from './commands/teleport/index.js'
  47. /* eslint-disable @typescript-eslint/no-require-imports */
  48. const agentsPlatform =
  49. process.env.USER_TYPE === 'ant'
  50. ? require('./commands/agents-platform/index.js').default
  51. : null
  52. /* eslint-enable @typescript-eslint/no-require-imports */
  53. import securityReview from './commands/security-review.js'
  54. import bughunter from './commands/bughunter/index.js'
  55. import terminalSetup from './commands/terminalSetup/index.js'
  56. import usage from './commands/usage/index.js'
  57. import theme from './commands/theme/index.js'
  58. import vim from './commands/vim/index.js'
  59. import { feature } from 'bun:bundle'
  60. // Dead code elimination: conditional imports
  61. /* eslint-disable @typescript-eslint/no-require-imports */
  62. const proactive =
  63. feature('PROACTIVE') || feature('KAIROS')
  64. ? require('./commands/proactive.js').default
  65. : null
  66. const briefCommand =
  67. feature('KAIROS') || feature('KAIROS_BRIEF')
  68. ? require('./commands/brief.js').default
  69. : null
  70. const assistantCommand = feature('KAIROS')
  71. ? require('./commands/assistant/index.js').default
  72. : null
  73. const bridge = feature('BRIDGE_MODE')
  74. ? require('./commands/bridge/index.js').default
  75. : null
  76. const remoteControlServerCommand =
  77. feature('DAEMON') && feature('BRIDGE_MODE')
  78. ? require('./commands/remoteControlServer/index.js').default
  79. : null
  80. const voiceCommand = feature('VOICE_MODE')
  81. ? require('./commands/voice/index.js').default
  82. : null
  83. const forceSnip = feature('HISTORY_SNIP')
  84. ? require('./commands/force-snip.js').default
  85. : null
  86. const workflowsCmd = feature('WORKFLOW_SCRIPTS')
  87. ? (
  88. require('./commands/workflows/index.js') as typeof import('./commands/workflows/index.js')
  89. ).default
  90. : null
  91. const webCmd = feature('CCR_REMOTE_SETUP')
  92. ? (
  93. require('./commands/remote-setup/index.js') as typeof import('./commands/remote-setup/index.js')
  94. ).default
  95. : null
  96. const clearSkillIndexCache = feature('EXPERIMENTAL_SKILL_SEARCH')
  97. ? (
  98. require('./services/skillSearch/localSearch.js') as typeof import('./services/skillSearch/localSearch.js')
  99. ).clearSkillIndexCache
  100. : null
  101. const subscribePr = feature('KAIROS_GITHUB_WEBHOOKS')
  102. ? require('./commands/subscribe-pr.js').default
  103. : null
  104. const ultraplan = feature('ULTRAPLAN')
  105. ? require('./commands/ultraplan.js').default
  106. : null
  107. const torch = feature('TORCH') ? require('./commands/torch.js').default : null
  108. const peersCmd = feature('UDS_INBOX')
  109. ? (
  110. require('./commands/peers/index.js') as typeof import('./commands/peers/index.js')
  111. ).default
  112. : null
  113. const forkCmd = feature('FORK_SUBAGENT')
  114. ? (
  115. require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
  116. ).default
  117. : null
  118. const buddy = feature('BUDDY')
  119. ? (
  120. require('./commands/buddy/index.js') as typeof import('./commands/buddy/index.js')
  121. ).default
  122. : null
  123. /* eslint-enable @typescript-eslint/no-require-imports */
  124. import thinkback from './commands/thinkback/index.js'
  125. import thinkbackPlay from './commands/thinkback-play/index.js'
  126. import permissions from './commands/permissions/index.js'
  127. import plan from './commands/plan/index.js'
  128. import fast from './commands/fast/index.js'
  129. import passes from './commands/passes/index.js'
  130. import privacySettings from './commands/privacy-settings/index.js'
  131. import hooks from './commands/hooks/index.js'
  132. import files from './commands/files/index.js'
  133. import branch from './commands/branch/index.js'
  134. import agents from './commands/agents/index.js'
  135. import plugin from './commands/plugin/index.js'
  136. import reloadPlugins from './commands/reload-plugins/index.js'
  137. import rewind from './commands/rewind/index.js'
  138. import heapDump from './commands/heapdump/index.js'
  139. import mockLimits from './commands/mock-limits/index.js'
  140. import bridgeKick from './commands/bridge-kick.js'
  141. import version from './commands/version.js'
  142. import summary from './commands/summary/index.js'
  143. import {
  144. resetLimits,
  145. resetLimitsNonInteractive,
  146. } from './commands/reset-limits/index.js'
  147. import antTrace from './commands/ant-trace/index.js'
  148. import perfIssue from './commands/perf-issue/index.js'
  149. import sandboxToggle from './commands/sandbox-toggle/index.js'
  150. import chrome from './commands/chrome/index.js'
  151. import stickers from './commands/stickers/index.js'
  152. import advisor from './commands/advisor.js'
  153. import { logError } from './utils/log.js'
  154. import { toError } from './utils/errors.js'
  155. import { logForDebugging } from './utils/debug.js'
  156. import {
  157. getSkillDirCommands,
  158. clearSkillCaches,
  159. getDynamicSkills,
  160. } from './skills/loadSkillsDir.js'
  161. import { getBundledSkills } from './skills/bundledSkills.js'
  162. import { getBuiltinPluginSkillCommands } from './plugins/builtinPlugins.js'
  163. import {
  164. getPluginCommands,
  165. clearPluginCommandCache,
  166. getPluginSkills,
  167. clearPluginSkillsCache,
  168. } from './utils/plugins/loadPluginCommands.js'
  169. import memoize from 'lodash-es/memoize.js'
  170. import { isUsing3PServices, isClaudeAISubscriber } from './utils/auth.js'
  171. import { isFirstPartyAnthropicBaseUrl } from './utils/model/providers.js'
  172. import env from './commands/env/index.js'
  173. import exit from './commands/exit/index.js'
  174. import exportCommand from './commands/export/index.js'
  175. import model from './commands/model/index.js'
  176. import tag from './commands/tag/index.js'
  177. import outputStyle from './commands/output-style/index.js'
  178. import remoteEnv from './commands/remote-env/index.js'
  179. import upgrade from './commands/upgrade/index.js'
  180. import {
  181. extraUsage,
  182. extraUsageNonInteractive,
  183. } from './commands/extra-usage/index.js'
  184. import rateLimitOptions from './commands/rate-limit-options/index.js'
  185. import statusline from './commands/statusline.js'
  186. import effort from './commands/effort/index.js'
  187. import stats from './commands/stats/index.js'
  188. // insights.ts is 113KB (3200 lines, includes diffLines/html rendering). Lazy
  189. // shim defers the heavy module until /insights is actually invoked.
  190. const usageReport: Command = {
  191. type: 'prompt',
  192. name: 'insights',
  193. description: 'Generate a report analyzing your Claude Code sessions',
  194. contentLength: 0,
  195. progressMessage: 'analyzing your sessions',
  196. source: 'builtin',
  197. async getPromptForCommand(args, context) {
  198. const real = (await import('./commands/insights.js')).default
  199. if (real.type !== 'prompt') throw new Error('unreachable')
  200. return real.getPromptForCommand(args, context)
  201. },
  202. }
  203. import oauthRefresh from './commands/oauth-refresh/index.js'
  204. import debugToolCall from './commands/debug-tool-call/index.js'
  205. import { getSettingSourceName } from './utils/settings/constants.js'
  206. import {
  207. type Command,
  208. getCommandName,
  209. isCommandEnabled,
  210. } from './types/command.js'
  211. // Re-export types from the centralized location
  212. export type {
  213. Command,
  214. CommandBase,
  215. CommandResultDisplay,
  216. LocalCommandResult,
  217. LocalJSXCommandContext,
  218. PromptCommand,
  219. ResumeEntrypoint,
  220. } from './types/command.js'
  221. export { getCommandName, isCommandEnabled } from './types/command.js'
  222. // Commands that get eliminated from the external build
  223. export const INTERNAL_ONLY_COMMANDS = [
  224. backfillSessions,
  225. breakCache,
  226. bughunter,
  227. commit,
  228. commitPushPr,
  229. ctx_viz,
  230. goodClaude,
  231. issue,
  232. initVerifiers,
  233. ...(forceSnip ? [forceSnip] : []),
  234. mockLimits,
  235. bridgeKick,
  236. version,
  237. ...(ultraplan ? [ultraplan] : []),
  238. ...(subscribePr ? [subscribePr] : []),
  239. resetLimits,
  240. resetLimitsNonInteractive,
  241. onboarding,
  242. share,
  243. summary,
  244. teleport,
  245. antTrace,
  246. perfIssue,
  247. env,
  248. oauthRefresh,
  249. debugToolCall,
  250. agentsPlatform,
  251. autofixPr,
  252. ].filter(Boolean)
  253. // Declared as a function so that we don't run this until getCommands is called,
  254. // since underlying functions read from config, which can't be read at module initialization time
  255. const COMMANDS = memoize((): Command[] => [
  256. addDir,
  257. advisor,
  258. agents,
  259. branch,
  260. btw,
  261. chrome,
  262. clear,
  263. color,
  264. compact,
  265. config,
  266. copy,
  267. desktop,
  268. context,
  269. contextNonInteractive,
  270. cost,
  271. diff,
  272. doctor,
  273. effort,
  274. exit,
  275. fast,
  276. files,
  277. heapDump,
  278. help,
  279. ide,
  280. init,
  281. keybindings,
  282. installGitHubApp,
  283. installSlackApp,
  284. mcp,
  285. memory,
  286. mobile,
  287. model,
  288. outputStyle,
  289. remoteEnv,
  290. plugin,
  291. pr_comments,
  292. releaseNotes,
  293. reloadPlugins,
  294. rename,
  295. resume,
  296. session,
  297. skills,
  298. stats,
  299. status,
  300. statusline,
  301. stickers,
  302. tag,
  303. theme,
  304. feedback,
  305. review,
  306. ultrareview,
  307. rewind,
  308. securityReview,
  309. terminalSetup,
  310. upgrade,
  311. extraUsage,
  312. extraUsageNonInteractive,
  313. rateLimitOptions,
  314. usage,
  315. usageReport,
  316. vim,
  317. ...(webCmd ? [webCmd] : []),
  318. ...(forkCmd ? [forkCmd] : []),
  319. ...(buddy ? [buddy] : []),
  320. ...(proactive ? [proactive] : []),
  321. ...(briefCommand ? [briefCommand] : []),
  322. ...(assistantCommand ? [assistantCommand] : []),
  323. ...(bridge ? [bridge] : []),
  324. ...(remoteControlServerCommand ? [remoteControlServerCommand] : []),
  325. ...(voiceCommand ? [voiceCommand] : []),
  326. thinkback,
  327. thinkbackPlay,
  328. permissions,
  329. plan,
  330. privacySettings,
  331. hooks,
  332. exportCommand,
  333. sandboxToggle,
  334. ...(!isUsing3PServices() ? [logout, login()] : []),
  335. passes,
  336. ...(peersCmd ? [peersCmd] : []),
  337. tasks,
  338. ...(workflowsCmd ? [workflowsCmd] : []),
  339. ...(torch ? [torch] : []),
  340. ...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO
  341. ? INTERNAL_ONLY_COMMANDS
  342. : []),
  343. ])
  344. export const builtInCommandNames = memoize(
  345. (): Set<string> =>
  346. new Set(COMMANDS().flatMap(_ => [_.name, ...(_.aliases ?? [])])),
  347. )
  348. async function getSkills(cwd: string): Promise<{
  349. skillDirCommands: Command[]
  350. pluginSkills: Command[]
  351. bundledSkills: Command[]
  352. builtinPluginSkills: Command[]
  353. }> {
  354. try {
  355. const [skillDirCommands, pluginSkills] = await Promise.all([
  356. getSkillDirCommands(cwd).catch(err => {
  357. logError(toError(err))
  358. logForDebugging(
  359. 'Skill directory commands failed to load, continuing without them',
  360. )
  361. return []
  362. }),
  363. getPluginSkills().catch(err => {
  364. logError(toError(err))
  365. logForDebugging('Plugin skills failed to load, continuing without them')
  366. return []
  367. }),
  368. ])
  369. // Bundled skills are registered synchronously at startup
  370. const bundledSkills = getBundledSkills()
  371. // Built-in plugin skills come from enabled built-in plugins
  372. const builtinPluginSkills = getBuiltinPluginSkillCommands()
  373. logForDebugging(
  374. `getSkills returning: ${skillDirCommands.length} skill dir commands, ${pluginSkills.length} plugin skills, ${bundledSkills.length} bundled skills, ${builtinPluginSkills.length} builtin plugin skills`,
  375. )
  376. return {
  377. skillDirCommands,
  378. pluginSkills,
  379. bundledSkills,
  380. builtinPluginSkills,
  381. }
  382. } catch (err) {
  383. // This should never happen since we catch at the Promise level, but defensive
  384. logError(toError(err))
  385. logForDebugging('Unexpected error in getSkills, returning empty')
  386. return {
  387. skillDirCommands: [],
  388. pluginSkills: [],
  389. bundledSkills: [],
  390. builtinPluginSkills: [],
  391. }
  392. }
  393. }
  394. /* eslint-disable @typescript-eslint/no-require-imports */
  395. const getWorkflowCommands = feature('WORKFLOW_SCRIPTS')
  396. ? (
  397. require('./tools/WorkflowTool/createWorkflowCommand.js') as typeof import('./tools/WorkflowTool/createWorkflowCommand.js')
  398. ).getWorkflowCommands
  399. : null
  400. /* eslint-enable @typescript-eslint/no-require-imports */
  401. /**
  402. * Filters commands by their declared `availability` (auth/provider requirement).
  403. * Commands without `availability` are treated as universal.
  404. * This runs before `isEnabled()` so that provider-gated commands are hidden
  405. * regardless of feature-flag state.
  406. *
  407. * Not memoized — auth state can change mid-session (e.g. after /login),
  408. * so this must be re-evaluated on every getCommands() call.
  409. */
  410. export function meetsAvailabilityRequirement(cmd: Command): boolean {
  411. if (!cmd.availability) return true
  412. for (const a of cmd.availability) {
  413. switch (a) {
  414. case 'claude-ai':
  415. if (isClaudeAISubscriber()) return true
  416. break
  417. case 'console':
  418. // Console API key user = direct 1P API customer (not 3P, not claude.ai).
  419. // Excludes 3P (Bedrock/Vertex/Foundry) who don't set ANTHROPIC_BASE_URL
  420. // and gateway users who proxy through a custom base URL.
  421. if (
  422. !isClaudeAISubscriber() &&
  423. !isUsing3PServices() &&
  424. isFirstPartyAnthropicBaseUrl()
  425. )
  426. return true
  427. break
  428. default: {
  429. const _exhaustive: never = a
  430. void _exhaustive
  431. break
  432. }
  433. }
  434. }
  435. return false
  436. }
  437. /**
  438. * Loads all command sources (skills, plugins, workflows). Memoized by cwd
  439. * because loading is expensive (disk I/O, dynamic imports).
  440. */
  441. const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
  442. const [
  443. { skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills },
  444. pluginCommands,
  445. workflowCommands,
  446. ] = await Promise.all([
  447. getSkills(cwd),
  448. getPluginCommands(),
  449. getWorkflowCommands ? getWorkflowCommands(cwd) : Promise.resolve([]),
  450. ])
  451. return [
  452. ...bundledSkills,
  453. ...builtinPluginSkills,
  454. ...skillDirCommands,
  455. ...workflowCommands,
  456. ...pluginCommands,
  457. ...pluginSkills,
  458. ...COMMANDS(),
  459. ]
  460. })
  461. /**
  462. * Returns commands available to the current user. The expensive loading is
  463. * memoized, but availability and isEnabled checks run fresh every call so
  464. * auth changes (e.g. /login) take effect immediately.
  465. */
  466. export async function getCommands(cwd: string): Promise<Command[]> {
  467. const allCommands = await loadAllCommands(cwd)
  468. // Get dynamic skills discovered during file operations
  469. const dynamicSkills = getDynamicSkills()
  470. // Build base commands without dynamic skills
  471. const baseCommands = allCommands.filter(
  472. _ => meetsAvailabilityRequirement(_) && isCommandEnabled(_),
  473. )
  474. if (dynamicSkills.length === 0) {
  475. return baseCommands
  476. }
  477. // Dedupe dynamic skills - only add if not already present
  478. const baseCommandNames = new Set(baseCommands.map(c => c.name))
  479. const uniqueDynamicSkills = dynamicSkills.filter(
  480. s =>
  481. !baseCommandNames.has(s.name) &&
  482. meetsAvailabilityRequirement(s) &&
  483. isCommandEnabled(s),
  484. )
  485. if (uniqueDynamicSkills.length === 0) {
  486. return baseCommands
  487. }
  488. // Insert dynamic skills after plugin skills but before built-in commands
  489. const builtInNames = new Set(COMMANDS().map(c => c.name))
  490. const insertIndex = baseCommands.findIndex(c => builtInNames.has(c.name))
  491. if (insertIndex === -1) {
  492. return [...baseCommands, ...uniqueDynamicSkills]
  493. }
  494. return [
  495. ...baseCommands.slice(0, insertIndex),
  496. ...uniqueDynamicSkills,
  497. ...baseCommands.slice(insertIndex),
  498. ]
  499. }
  500. /**
  501. * Clears only the memoization caches for commands, WITHOUT clearing skill caches.
  502. * Use this when dynamic skills are added to invalidate cached command lists.
  503. */
  504. export function clearCommandMemoizationCaches(): void {
  505. loadAllCommands.cache?.clear?.()
  506. getSkillToolCommands.cache?.clear?.()
  507. getSlashCommandToolSkills.cache?.clear?.()
  508. // getSkillIndex in skillSearch/localSearch.ts is a separate memoization layer
  509. // built ON TOP of getSkillToolCommands/getCommands. Clearing only the inner
  510. // caches is a no-op for the outer — lodash memoize returns the cached result
  511. // without ever reaching the cleared inners. Must clear it explicitly.
  512. clearSkillIndexCache?.()
  513. }
  514. export function clearCommandsCache(): void {
  515. clearCommandMemoizationCaches()
  516. clearPluginCommandCache()
  517. clearPluginSkillsCache()
  518. clearSkillCaches()
  519. }
  520. /**
  521. * Filter AppState.mcp.commands to MCP-provided skills (prompt-type,
  522. * model-invocable, loaded from MCP). These live outside getCommands() so
  523. * callers that need MCP skills in their skill index thread them through
  524. * separately.
  525. */
  526. export function getMcpSkillCommands(
  527. mcpCommands: readonly Command[],
  528. ): readonly Command[] {
  529. if (feature('MCP_SKILLS')) {
  530. return mcpCommands.filter(
  531. cmd =>
  532. cmd.type === 'prompt' &&
  533. cmd.loadedFrom === 'mcp' &&
  534. !cmd.disableModelInvocation,
  535. )
  536. }
  537. return []
  538. }
  539. // SkillTool shows ALL prompt-based commands that the model can invoke
  540. // This includes both skills (from /skills/) and commands (from /commands/)
  541. export const getSkillToolCommands = memoize(
  542. async (cwd: string): Promise<Command[]> => {
  543. const allCommands = await getCommands(cwd)
  544. return allCommands.filter(
  545. cmd =>
  546. cmd.type === 'prompt' &&
  547. !cmd.disableModelInvocation &&
  548. cmd.source !== 'builtin' &&
  549. // Always include skills from /skills/ dirs, bundled skills, and legacy /commands/ entries
  550. // (they all get an auto-derived description from the first line if frontmatter is missing).
  551. // Plugin/MCP commands still require an explicit description to appear in the listing.
  552. (cmd.loadedFrom === 'bundled' ||
  553. cmd.loadedFrom === 'skills' ||
  554. cmd.loadedFrom === 'commands_DEPRECATED' ||
  555. cmd.hasUserSpecifiedDescription ||
  556. cmd.whenToUse),
  557. )
  558. },
  559. )
  560. // Filters commands to include only skills. Skills are commands that provide
  561. // specialized capabilities for the model to use. They are identified by
  562. // loadedFrom being 'skills', 'plugin', or 'bundled', or having disableModelInvocation set.
  563. export const getSlashCommandToolSkills = memoize(
  564. async (cwd: string): Promise<Command[]> => {
  565. try {
  566. const allCommands = await getCommands(cwd)
  567. return allCommands.filter(
  568. cmd =>
  569. cmd.type === 'prompt' &&
  570. cmd.source !== 'builtin' &&
  571. (cmd.hasUserSpecifiedDescription || cmd.whenToUse) &&
  572. (cmd.loadedFrom === 'skills' ||
  573. cmd.loadedFrom === 'plugin' ||
  574. cmd.loadedFrom === 'bundled' ||
  575. cmd.disableModelInvocation),
  576. )
  577. } catch (error) {
  578. logError(toError(error))
  579. // Return empty array rather than throwing - skills are non-critical
  580. // This prevents skill loading failures from breaking the entire system
  581. logForDebugging('Returning empty skills array due to load failure')
  582. return []
  583. }
  584. },
  585. )
  586. /**
  587. * Commands that are safe to use in remote mode (--remote).
  588. * These only affect local TUI state and don't depend on local filesystem,
  589. * git, shell, IDE, MCP, or other local execution context.
  590. *
  591. * Used in two places:
  592. * 1. Pre-filtering commands in main.tsx before REPL renders (prevents race with CCR init)
  593. * 2. Preserving local-only commands in REPL's handleRemoteInit after CCR filters
  594. */
  595. export const REMOTE_SAFE_COMMANDS: Set<Command> = new Set([
  596. session, // Shows QR code / URL for remote session
  597. exit, // Exit the TUI
  598. clear, // Clear screen
  599. help, // Show help
  600. theme, // Change terminal theme
  601. color, // Change agent color
  602. vim, // Toggle vim mode
  603. cost, // Show session cost (local cost tracking)
  604. usage, // Show usage info
  605. copy, // Copy last message
  606. btw, // Quick note
  607. feedback, // Send feedback
  608. plan, // Plan mode toggle
  609. keybindings, // Keybinding management
  610. statusline, // Status line toggle
  611. stickers, // Stickers
  612. mobile, // Mobile QR code
  613. ])
  614. /**
  615. * Builtin commands of type 'local' that ARE safe to execute when received
  616. * over the Remote Control bridge. These produce text output that streams
  617. * back to the mobile/web client and have no terminal-only side effects.
  618. *
  619. * 'local-jsx' commands are blocked by type (they render Ink UI) and
  620. * 'prompt' commands are allowed by type (they expand to text sent to the
  621. * model) — this set only gates 'local' commands.
  622. *
  623. * When adding a new 'local' command that should work from mobile, add it
  624. * here. Default is blocked.
  625. */
  626. export const BRIDGE_SAFE_COMMANDS: Set<Command> = new Set(
  627. [
  628. compact, // Shrink context — useful mid-session from a phone
  629. clear, // Wipe transcript
  630. cost, // Show session cost
  631. summary, // Summarize conversation
  632. releaseNotes, // Show changelog
  633. files, // List tracked files
  634. ].filter((c): c is Command => c !== null),
  635. )
  636. /**
  637. * Whether a slash command is safe to execute when its input arrived over the
  638. * Remote Control bridge (mobile/web client).
  639. *
  640. * PR #19134 blanket-blocked all slash commands from bridge inbound because
  641. * `/model` from iOS was popping the local Ink picker. This predicate relaxes
  642. * that with an explicit allowlist: 'prompt' commands (skills) expand to text
  643. * and are safe by construction; 'local' commands need an explicit opt-in via
  644. * BRIDGE_SAFE_COMMANDS; 'local-jsx' commands render Ink UI and stay blocked.
  645. */
  646. export function isBridgeSafeCommand(cmd: Command): boolean {
  647. if (cmd.type === 'local-jsx') return false
  648. if (cmd.type === 'prompt') return true
  649. return BRIDGE_SAFE_COMMANDS.has(cmd)
  650. }
  651. /**
  652. * Filter commands to only include those safe for remote mode.
  653. * Used to pre-filter commands when rendering the REPL in --remote mode,
  654. * preventing local-only commands from being briefly available before
  655. * the CCR init message arrives.
  656. */
  657. export function filterCommandsForRemoteMode(commands: Command[]): Command[] {
  658. return commands.filter(cmd => REMOTE_SAFE_COMMANDS.has(cmd))
  659. }
  660. export function findCommand(
  661. commandName: string,
  662. commands: Command[],
  663. ): Command | undefined {
  664. return commands.find(
  665. _ =>
  666. _.name === commandName ||
  667. getCommandName(_) === commandName ||
  668. _.aliases?.includes(commandName),
  669. )
  670. }
  671. export function hasCommand(commandName: string, commands: Command[]): boolean {
  672. return findCommand(commandName, commands) !== undefined
  673. }
  674. export function getCommand(commandName: string, commands: Command[]): Command {
  675. const command = findCommand(commandName, commands)
  676. if (!command) {
  677. throw ReferenceError(
  678. `Command ${commandName} not found. Available commands: ${commands
  679. .map(_ => {
  680. const name = getCommandName(_)
  681. return _.aliases ? `${name} (aliases: ${_.aliases.join(', ')})` : name
  682. })
  683. .sort((a, b) => a.localeCompare(b))
  684. .join(', ')}`,
  685. )
  686. }
  687. return command
  688. }
  689. /**
  690. * Formats a command's description with its source annotation for user-facing UI.
  691. * Use this in typeahead, help screens, and other places where users need to see
  692. * where a command comes from.
  693. *
  694. * For model-facing prompts (like SkillTool), use cmd.description directly.
  695. */
  696. export function formatDescriptionWithSource(cmd: Command): string {
  697. if (cmd.type !== 'prompt') {
  698. return cmd.description
  699. }
  700. if (cmd.kind === 'workflow') {
  701. return `${cmd.description} (workflow)`
  702. }
  703. if (cmd.source === 'plugin') {
  704. const pluginName = cmd.pluginInfo?.pluginManifest.name
  705. if (pluginName) {
  706. return `(${pluginName}) ${cmd.description}`
  707. }
  708. return `${cmd.description} (plugin)`
  709. }
  710. if (cmd.source === 'builtin' || cmd.source === 'mcp') {
  711. return cmd.description
  712. }
  713. if (cmd.source === 'bundled') {
  714. return `${cmd.description} (bundled)`
  715. }
  716. return `${cmd.description} (${getSettingSourceName(cmd.source)})`
  717. }