/ commands.ts
commands.ts
  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  
212  // Re-export types from the centralized location
213  export type {
214    Command,
215    CommandBase,
216    CommandResultDisplay,
217    LocalCommandResult,
218    LocalJSXCommandContext,
219    PromptCommand,
220    ResumeEntrypoint,
221  } from './types/command.js'
222  export { getCommandName, isCommandEnabled } from './types/command.js'
223  
224  // Commands that get eliminated from the external build
225  export const INTERNAL_ONLY_COMMANDS = [
226    backfillSessions,
227    breakCache,
228    bughunter,
229    commit,
230    commitPushPr,
231    ctx_viz,
232    goodClaude,
233    issue,
234    initVerifiers,
235    ...(forceSnip ? [forceSnip] : []),
236    mockLimits,
237    bridgeKick,
238    version,
239    ...(ultraplan ? [ultraplan] : []),
240    ...(subscribePr ? [subscribePr] : []),
241    resetLimits,
242    resetLimitsNonInteractive,
243    onboarding,
244    share,
245    summary,
246    teleport,
247    antTrace,
248    perfIssue,
249    env,
250    oauthRefresh,
251    debugToolCall,
252    agentsPlatform,
253    autofixPr,
254  ].filter(Boolean)
255  
256  // Declared as a function so that we don't run this until getCommands is called,
257  // since underlying functions read from config, which can't be read at module initialization time
258  const COMMANDS = memoize((): Command[] => [
259    addDir,
260    advisor,
261    agents,
262    branch,
263    btw,
264    chrome,
265    clear,
266    color,
267    compact,
268    config,
269    copy,
270    desktop,
271    context,
272    contextNonInteractive,
273    cost,
274    diff,
275    doctor,
276    effort,
277    exit,
278    fast,
279    files,
280    heapDump,
281    help,
282    ide,
283    init,
284    keybindings,
285    installGitHubApp,
286    installSlackApp,
287    mcp,
288    memory,
289    mobile,
290    model,
291    outputStyle,
292    remoteEnv,
293    plugin,
294    pr_comments,
295    releaseNotes,
296    reloadPlugins,
297    rename,
298    resume,
299    session,
300    skills,
301    stats,
302    status,
303    statusline,
304    stickers,
305    tag,
306    theme,
307    feedback,
308    review,
309    ultrareview,
310    rewind,
311    securityReview,
312    terminalSetup,
313    upgrade,
314    extraUsage,
315    extraUsageNonInteractive,
316    rateLimitOptions,
317    usage,
318    usageReport,
319    vim,
320    ...(webCmd ? [webCmd] : []),
321    ...(forkCmd ? [forkCmd] : []),
322    ...(buddy ? [buddy] : []),
323    ...(proactive ? [proactive] : []),
324    ...(briefCommand ? [briefCommand] : []),
325    ...(assistantCommand ? [assistantCommand] : []),
326    ...(bridge ? [bridge] : []),
327    ...(remoteControlServerCommand ? [remoteControlServerCommand] : []),
328    ...(voiceCommand ? [voiceCommand] : []),
329    thinkback,
330    thinkbackPlay,
331    permissions,
332    plan,
333    privacySettings,
334    hooks,
335    exportCommand,
336    sandboxToggle,
337    ...(!isUsing3PServices() ? [logout, login()] : []),
338    passes,
339    ...(peersCmd ? [peersCmd] : []),
340    tasks,
341    ...(workflowsCmd ? [workflowsCmd] : []),
342    ...(torch ? [torch] : []),
343    ...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO
344      ? INTERNAL_ONLY_COMMANDS
345      : []),
346  ])
347  
348  export const builtInCommandNames = memoize(
349    (): Set<string> =>
350      new Set(COMMANDS().flatMap(_ => [_.name, ...(_.aliases ?? [])])),
351  )
352  
353  async function getSkills(cwd: string): Promise<{
354    skillDirCommands: Command[]
355    pluginSkills: Command[]
356    bundledSkills: Command[]
357    builtinPluginSkills: Command[]
358  }> {
359    try {
360      const [skillDirCommands, pluginSkills] = await Promise.all([
361        getSkillDirCommands(cwd).catch(err => {
362          logError(toError(err))
363          logForDebugging(
364            'Skill directory commands failed to load, continuing without them',
365          )
366          return []
367        }),
368        getPluginSkills().catch(err => {
369          logError(toError(err))
370          logForDebugging('Plugin skills failed to load, continuing without them')
371          return []
372        }),
373      ])
374      // Bundled skills are registered synchronously at startup
375      const bundledSkills = getBundledSkills()
376      // Built-in plugin skills come from enabled built-in plugins
377      const builtinPluginSkills = getBuiltinPluginSkillCommands()
378      logForDebugging(
379        `getSkills returning: ${skillDirCommands.length} skill dir commands, ${pluginSkills.length} plugin skills, ${bundledSkills.length} bundled skills, ${builtinPluginSkills.length} builtin plugin skills`,
380      )
381      return {
382        skillDirCommands,
383        pluginSkills,
384        bundledSkills,
385        builtinPluginSkills,
386      }
387    } catch (err) {
388      // This should never happen since we catch at the Promise level, but defensive
389      logError(toError(err))
390      logForDebugging('Unexpected error in getSkills, returning empty')
391      return {
392        skillDirCommands: [],
393        pluginSkills: [],
394        bundledSkills: [],
395        builtinPluginSkills: [],
396      }
397    }
398  }
399  
400  /* eslint-disable @typescript-eslint/no-require-imports */
401  const getWorkflowCommands = feature('WORKFLOW_SCRIPTS')
402    ? (
403        require('./tools/WorkflowTool/createWorkflowCommand.js') as typeof import('./tools/WorkflowTool/createWorkflowCommand.js')
404      ).getWorkflowCommands
405    : null
406  /* eslint-enable @typescript-eslint/no-require-imports */
407  
408  /**
409   * Filters commands by their declared `availability` (auth/provider requirement).
410   * Commands without `availability` are treated as universal.
411   * This runs before `isEnabled()` so that provider-gated commands are hidden
412   * regardless of feature-flag state.
413   *
414   * Not memoized — auth state can change mid-session (e.g. after /login),
415   * so this must be re-evaluated on every getCommands() call.
416   */
417  export function meetsAvailabilityRequirement(cmd: Command): boolean {
418    if (!cmd.availability) return true
419    for (const a of cmd.availability) {
420      switch (a) {
421        case 'claude-ai':
422          if (isClaudeAISubscriber()) return true
423          break
424        case 'console':
425          // Console API key user = direct 1P API customer (not 3P, not claude.ai).
426          // Excludes 3P (Bedrock/Vertex/Foundry) who don't set ANTHROPIC_BASE_URL
427          // and gateway users who proxy through a custom base URL.
428          if (
429            !isClaudeAISubscriber() &&
430            !isUsing3PServices() &&
431            isFirstPartyAnthropicBaseUrl()
432          )
433            return true
434          break
435        default: {
436          const _exhaustive: never = a
437          void _exhaustive
438          break
439        }
440      }
441    }
442    return false
443  }
444  
445  /**
446   * Loads all command sources (skills, plugins, workflows). Memoized by cwd
447   * because loading is expensive (disk I/O, dynamic imports).
448   */
449  const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
450    const [
451      { skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills },
452      pluginCommands,
453      workflowCommands,
454    ] = await Promise.all([
455      getSkills(cwd),
456      getPluginCommands(),
457      getWorkflowCommands ? getWorkflowCommands(cwd) : Promise.resolve([]),
458    ])
459  
460    return [
461      ...bundledSkills,
462      ...builtinPluginSkills,
463      ...skillDirCommands,
464      ...workflowCommands,
465      ...pluginCommands,
466      ...pluginSkills,
467      ...COMMANDS(),
468    ]
469  })
470  
471  /**
472   * Returns commands available to the current user. The expensive loading is
473   * memoized, but availability and isEnabled checks run fresh every call so
474   * auth changes (e.g. /login) take effect immediately.
475   */
476  export async function getCommands(cwd: string): Promise<Command[]> {
477    const allCommands = await loadAllCommands(cwd)
478  
479    // Get dynamic skills discovered during file operations
480    const dynamicSkills = getDynamicSkills()
481  
482    // Build base commands without dynamic skills
483    const baseCommands = allCommands.filter(
484      _ => meetsAvailabilityRequirement(_) && isCommandEnabled(_),
485    )
486  
487    if (dynamicSkills.length === 0) {
488      return baseCommands
489    }
490  
491    // Dedupe dynamic skills - only add if not already present
492    const baseCommandNames = new Set(baseCommands.map(c => c.name))
493    const uniqueDynamicSkills = dynamicSkills.filter(
494      s =>
495        !baseCommandNames.has(s.name) &&
496        meetsAvailabilityRequirement(s) &&
497        isCommandEnabled(s),
498    )
499  
500    if (uniqueDynamicSkills.length === 0) {
501      return baseCommands
502    }
503  
504    // Insert dynamic skills after plugin skills but before built-in commands
505    const builtInNames = new Set(COMMANDS().map(c => c.name))
506    const insertIndex = baseCommands.findIndex(c => builtInNames.has(c.name))
507  
508    if (insertIndex === -1) {
509      return [...baseCommands, ...uniqueDynamicSkills]
510    }
511  
512    return [
513      ...baseCommands.slice(0, insertIndex),
514      ...uniqueDynamicSkills,
515      ...baseCommands.slice(insertIndex),
516    ]
517  }
518  
519  /**
520   * Clears only the memoization caches for commands, WITHOUT clearing skill caches.
521   * Use this when dynamic skills are added to invalidate cached command lists.
522   */
523  export function clearCommandMemoizationCaches(): void {
524    loadAllCommands.cache?.clear?.()
525    getSkillToolCommands.cache?.clear?.()
526    getSlashCommandToolSkills.cache?.clear?.()
527    // getSkillIndex in skillSearch/localSearch.ts is a separate memoization layer
528    // built ON TOP of getSkillToolCommands/getCommands. Clearing only the inner
529    // caches is a no-op for the outer — lodash memoize returns the cached result
530    // without ever reaching the cleared inners. Must clear it explicitly.
531    clearSkillIndexCache?.()
532  }
533  
534  export function clearCommandsCache(): void {
535    clearCommandMemoizationCaches()
536    clearPluginCommandCache()
537    clearPluginSkillsCache()
538    clearSkillCaches()
539  }
540  
541  /**
542   * Filter AppState.mcp.commands to MCP-provided skills (prompt-type,
543   * model-invocable, loaded from MCP). These live outside getCommands() so
544   * callers that need MCP skills in their skill index thread them through
545   * separately.
546   */
547  export function getMcpSkillCommands(
548    mcpCommands: readonly Command[],
549  ): readonly Command[] {
550    if (feature('MCP_SKILLS')) {
551      return mcpCommands.filter(
552        cmd =>
553          cmd.type === 'prompt' &&
554          cmd.loadedFrom === 'mcp' &&
555          !cmd.disableModelInvocation,
556      )
557    }
558    return []
559  }
560  
561  // SkillTool shows ALL prompt-based commands that the model can invoke
562  // This includes both skills (from /skills/) and commands (from /commands/)
563  export const getSkillToolCommands = memoize(
564    async (cwd: string): Promise<Command[]> => {
565      const allCommands = await getCommands(cwd)
566      return allCommands.filter(
567        cmd =>
568          cmd.type === 'prompt' &&
569          !cmd.disableModelInvocation &&
570          cmd.source !== 'builtin' &&
571          // Always include skills from /skills/ dirs, bundled skills, and legacy /commands/ entries
572          // (they all get an auto-derived description from the first line if frontmatter is missing).
573          // Plugin/MCP commands still require an explicit description to appear in the listing.
574          (cmd.loadedFrom === 'bundled' ||
575            cmd.loadedFrom === 'skills' ||
576            cmd.loadedFrom === 'commands_DEPRECATED' ||
577            cmd.hasUserSpecifiedDescription ||
578            cmd.whenToUse),
579      )
580    },
581  )
582  
583  // Filters commands to include only skills. Skills are commands that provide
584  // specialized capabilities for the model to use. They are identified by
585  // loadedFrom being 'skills', 'plugin', or 'bundled', or having disableModelInvocation set.
586  export const getSlashCommandToolSkills = memoize(
587    async (cwd: string): Promise<Command[]> => {
588      try {
589        const allCommands = await getCommands(cwd)
590        return allCommands.filter(
591          cmd =>
592            cmd.type === 'prompt' &&
593            cmd.source !== 'builtin' &&
594            (cmd.hasUserSpecifiedDescription || cmd.whenToUse) &&
595            (cmd.loadedFrom === 'skills' ||
596              cmd.loadedFrom === 'plugin' ||
597              cmd.loadedFrom === 'bundled' ||
598              cmd.disableModelInvocation),
599        )
600      } catch (error) {
601        logError(toError(error))
602        // Return empty array rather than throwing - skills are non-critical
603        // This prevents skill loading failures from breaking the entire system
604        logForDebugging('Returning empty skills array due to load failure')
605        return []
606      }
607    },
608  )
609  
610  /**
611   * Commands that are safe to use in remote mode (--remote).
612   * These only affect local TUI state and don't depend on local filesystem,
613   * git, shell, IDE, MCP, or other local execution context.
614   *
615   * Used in two places:
616   * 1. Pre-filtering commands in main.tsx before REPL renders (prevents race with CCR init)
617   * 2. Preserving local-only commands in REPL's handleRemoteInit after CCR filters
618   */
619  export const REMOTE_SAFE_COMMANDS: Set<Command> = new Set([
620    session, // Shows QR code / URL for remote session
621    exit, // Exit the TUI
622    clear, // Clear screen
623    help, // Show help
624    theme, // Change terminal theme
625    color, // Change agent color
626    vim, // Toggle vim mode
627    cost, // Show session cost (local cost tracking)
628    usage, // Show usage info
629    copy, // Copy last message
630    btw, // Quick note
631    feedback, // Send feedback
632    plan, // Plan mode toggle
633    keybindings, // Keybinding management
634    statusline, // Status line toggle
635    stickers, // Stickers
636    mobile, // Mobile QR code
637  ])
638  
639  /**
640   * Builtin commands of type 'local' that ARE safe to execute when received
641   * over the Remote Control bridge. These produce text output that streams
642   * back to the mobile/web client and have no terminal-only side effects.
643   *
644   * 'local-jsx' commands are blocked by type (they render Ink UI) and
645   * 'prompt' commands are allowed by type (they expand to text sent to the
646   * model) — this set only gates 'local' commands.
647   *
648   * When adding a new 'local' command that should work from mobile, add it
649   * here. Default is blocked.
650   */
651  export const BRIDGE_SAFE_COMMANDS: Set<Command> = new Set(
652    [
653      compact, // Shrink context — useful mid-session from a phone
654      clear, // Wipe transcript
655      cost, // Show session cost
656      summary, // Summarize conversation
657      releaseNotes, // Show changelog
658      files, // List tracked files
659    ].filter((c): c is Command => c !== null),
660  )
661  
662  /**
663   * Whether a slash command is safe to execute when its input arrived over the
664   * Remote Control bridge (mobile/web client).
665   *
666   * PR #19134 blanket-blocked all slash commands from bridge inbound because
667   * `/model` from iOS was popping the local Ink picker. This predicate relaxes
668   * that with an explicit allowlist: 'prompt' commands (skills) expand to text
669   * and are safe by construction; 'local' commands need an explicit opt-in via
670   * BRIDGE_SAFE_COMMANDS; 'local-jsx' commands render Ink UI and stay blocked.
671   */
672  export function isBridgeSafeCommand(cmd: Command): boolean {
673    if (cmd.type === 'local-jsx') return false
674    if (cmd.type === 'prompt') return true
675    return BRIDGE_SAFE_COMMANDS.has(cmd)
676  }
677  
678  /**
679   * Filter commands to only include those safe for remote mode.
680   * Used to pre-filter commands when rendering the REPL in --remote mode,
681   * preventing local-only commands from being briefly available before
682   * the CCR init message arrives.
683   */
684  export function filterCommandsForRemoteMode(commands: Command[]): Command[] {
685    return commands.filter(cmd => REMOTE_SAFE_COMMANDS.has(cmd))
686  }
687  
688  export function findCommand(
689    commandName: string,
690    commands: Command[],
691  ): Command | undefined {
692    return commands.find(
693      _ =>
694        _.name === commandName ||
695        getCommandName(_) === commandName ||
696        _.aliases?.includes(commandName),
697    )
698  }
699  
700  export function hasCommand(commandName: string, commands: Command[]): boolean {
701    return findCommand(commandName, commands) !== undefined
702  }
703  
704  export function getCommand(commandName: string, commands: Command[]): Command {
705    const command = findCommand(commandName, commands)
706    if (!command) {
707      throw ReferenceError(
708        `Command ${commandName} not found. Available commands: ${commands
709          .map(_ => {
710            const name = getCommandName(_)
711            return _.aliases ? `${name} (aliases: ${_.aliases.join(', ')})` : name
712          })
713          .sort((a, b) => a.localeCompare(b))
714          .join(', ')}`,
715      )
716    }
717  
718    return command
719  }
720  
721  /**
722   * Formats a command's description with its source annotation for user-facing UI.
723   * Use this in typeahead, help screens, and other places where users need to see
724   * where a command comes from.
725   *
726   * For model-facing prompts (like SkillTool), use cmd.description directly.
727   */
728  export function formatDescriptionWithSource(cmd: Command): string {
729    if (cmd.type !== 'prompt') {
730      return cmd.description
731    }
732  
733    if (cmd.kind === 'workflow') {
734      return `${cmd.description} (workflow)`
735    }
736  
737    if (cmd.source === 'plugin') {
738      const pluginName = cmd.pluginInfo?.pluginManifest.name
739      if (pluginName) {
740        return `(${pluginName}) ${cmd.description}`
741      }
742      return `${cmd.description} (plugin)`
743    }
744  
745    if (cmd.source === 'builtin' || cmd.source === 'mcp') {
746      return cmd.description
747    }
748  
749    if (cmd.source === 'bundled') {
750      return `${cmd.description} (bundled)`
751    }
752  
753    return `${cmd.description} (${getSettingSourceName(cmd.source)})`
754  }