feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,589 @@
# dexto
## 1.5.6
### Patch Changes
- b805d2a: Add support for package version check and prompt user to update. Added `sync-agents` command and auto-prompt on startup for user to update agent configs. Updated docs for installation and ink-cli slash commands.
- 042f4f0: ### CLI Improvements
- Add `/export` command to export conversations as Markdown or JSON
- Add `Ctrl+T` toggle for task list visibility during processing
- Improve task list UI with collapsible view near the processing message
- Fix race condition causing duplicate rendering (mainly visible with explore tool)
- Don't truncate `pattern` and `question` args in tool output display
### Bug Fixes
- Fix build script to preserve `.dexto` storage (conversations, logs) during clean builds
- Fix `@dexto/tools-todo` versioning - add to fixed version group in changeset config
### Configuration Changes
- Remove approval timeout defaults - now waits indefinitely (better UX for CLI)
- Add package versioning guidelines to AGENTS.md
- Updated dependencies [042f4f0]
- @dexto/agent-management@1.5.6
- @dexto/image-local@1.5.6
- @dexto/analytics@1.5.6
- @dexto/registry@1.5.6
- @dexto/server@1.5.6
- @dexto/core@1.5.6
## 1.5.5
### Patch Changes
- 9ab3eac: Added todo tools.
- 63fa083: Session and context management fixes:
- Remove continuation session logic after compaction, now sticks to same session
- `/clear` continues same session and resets context (frees up AI context window)
- `/new` command creates new session with fresh context and clears screen
- Add context tokens remaining to footer, align context calculations everywhere
- Fix context calculation logic by including cache read tokens
Other improvements:
- Fix code block syntax highlighting in terminal (uses cli-highlight)
- Make terminal the default mode during onboarding
- Reduce OTEL dependency bloat by replacing auto-instrumentation with specific packages (47 MB saved: 65 MB → 18 MB)
- 6df3ca9: Updated readme. Removed stale filesystem and process tool from dexto/core.
- Updated dependencies [9ab3eac]
- Updated dependencies [63fa083]
- Updated dependencies [6df3ca9]
- @dexto/image-local@1.5.5
- @dexto/core@1.5.5
- @dexto/registry@1.5.5
- @dexto/server@1.5.5
- @dexto/agent-management@1.5.5
- @dexto/analytics@1.5.5
## 1.5.4
### Patch Changes
- 0016cd3: Bug fixes and updates for compaction. Also added UI enhancements for compaction.
- 499b890: Fix model override persistence after compaction and improve context token tracking
**Bug Fixes:**
- Fix model override resetting to config model after compaction (now respects session overrides)
**Context Tracking Improvements:**
- New algorithm uses actual `input_tokens` and `output_tokens` from LLM responses as source of truth
- Self-correcting estimates: inaccuracies auto-correct when next LLM response arrives
- Handles pruning automatically (next response's input_tokens reflects pruned state)
- `/context` and compaction decisions now share common calculation logic
- Removed `outputBuffer` concept in favor of single configurable threshold
- Default compaction threshold lowered to 90%
**New `/context` Command:**
- Interactive overlay with stacked token bar visualization
- Breakdown by component: system prompt, tools, messages, free space, auto-compact buffer
- Expandable per-tool token details
- Shows pruned tool count and compaction history
**Observability:**
- Comparison logging between estimated vs actual tokens for calibration
- `dexto_llm_tokens_consumed` metric now includes estimated input tokens and accuracy metrics
- aa2c9a0: - new --dev flag for using dev mode with the CLI (for maintainers) (sets DEXTO_DEV_MODE=true and ensures local files are used)
- improved bash tool descriptions
- fixed explore agent task description getting truncated
- fixed some alignment issues
- fix search/find tools not asking approval for working outside directory
- add sound feature (sounds when approval reqd, when loop done)
- configurable in `preferences.yml` (on by default) and in `~/.dexto/sounds`, instructions in comment in `~/.dexto/preferences.yml`
- add new `env` system prompt contributor that includes info about os, working directory, git status. useful for coding agent to get enough context to improve cmd construction without unnecessary directory shifts
- support for loading `.claude/commands` and `.cursor/commands` global and local commands in addition to `.dexto/commands`
- Updated dependencies [0016cd3]
- Updated dependencies [499b890]
- Updated dependencies [aa2c9a0]
- @dexto/core@1.5.4
- @dexto/analytics@1.5.4
- @dexto/agent-management@1.5.4
- @dexto/image-local@1.5.4
- @dexto/server@1.5.4
- @dexto/registry@1.5.4
## 1.5.3
### Patch Changes
- 4f00295: Added spawn-agent tools and explore agent.
- 69c944c: File integrity & performance improvements, approval system fixes, and developer experience enhancements
### File System Improvements
- **File integrity protection**: Store file hashes to prevent edits from corrupting files when content changes between operations (resolves #516)
- **Performance optimization**: Disable backups and remove redundant reads, switch to async non-blocking reads for faster file writes
### Approval System Fixes
- **Coding agent auto-approve**: Fix auto-approve not working due to incorrect tool names in auto-approve policies
- **Parallel tool calls**: Fix multiple parallel same-tool calls requiring redundant approvals - now checks all waiting approvals and resolves ones affected by newly approved commands
- **Refactored CLI approval handler**: Decoupled approval handler pattern from server for better separation of concerns
### Shell & Scripting Fixes
- **Bash mode aliases**: Fix bash mode not honoring zsh aliases
- **Script improvements**: Miscellaneous script improvements for better developer experience
- Updated dependencies [4f00295]
- Updated dependencies [69c944c]
- @dexto/agent-management@1.5.3
- @dexto/image-local@1.5.3
- @dexto/core@1.5.3
- @dexto/server@1.5.3
- @dexto/analytics@1.5.3
- @dexto/registry@1.5.3
## 1.5.2
### Patch Changes
- 8a85ea4: Fix maxsteps in agent loop causing early termination
- 527f3f9: Fixes for interactive CLI
- Updated dependencies [91acb03]
- Updated dependencies [8a85ea4]
- Updated dependencies [527f3f9]
- @dexto/agent-management@1.5.2
- @dexto/core@1.5.2
- @dexto/analytics@1.5.2
- @dexto/server@1.5.2
- @dexto/image-local@1.5.2
- @dexto/registry@1.5.2
## 1.5.1
### Patch Changes
- a25d3ee: Add shell command execution (`!command` shortcut), token counting display, and auto-discovery of agent instruction files (agent.md, claude.md, gemini.md)
- bfcc7b1: PostgreSQL improvements and privacy mode
**PostgreSQL enhancements:**
- Add connection resilience for serverless databases (Neon, Supabase, etc.) with automatic retry on connection failures
- Support custom PostgreSQL schemas via `options.schema` config
- Add schema name validation to prevent SQL injection
- Improve connection pool error handling to prevent process crashes
**Privacy mode:**
- Add `--privacy-mode` CLI flag to hide file paths from output (useful for screen recording/sharing)
- Can also be enabled via `DEXTO_PRIVACY_MODE=true` environment variable
**Session improvements:**
- Add message deduplication in history provider to handle data corruption gracefully
- Add warning when conversation history hits 10k message limit
- Improve session deletion to ensure messages are always cleaned up
**Other fixes:**
- Sanitize explicit `agentId` for filesystem safety
- Change verbose flush logs to debug level
- Export `BaseTypedEventEmitter` from events module
- 4aabdb7: Fix claude caching, added gpt-5.2 models and reasoning effort options in user flows.
- Updated dependencies [a25d3ee]
- Updated dependencies [bfcc7b1]
- Updated dependencies [4aabdb7]
- @dexto/agent-management@1.5.1
- @dexto/core@1.5.1
- @dexto/server@1.5.1
- @dexto/analytics@1.5.1
- @dexto/image-local@1.5.1
- @dexto/registry@1.5.1
## 1.5.0
### Minor Changes
- e7722e5: Minor version bump for new release with bundler, custom tool pkgs, etc.
### Patch Changes
- abfe5ce: Update and standardize analytics for CLI and web UI
- ee12727: Added support for node-llama (llama.cpp) for local GGUF models. Added Ollama as first-class provider. Updated onboarding/setup flow.
- 1e7e974: Added image bundler, @dexto/image-local and moved tool services outside core. Added registry providers to select core services.
- 4c05310: Improve local model/GGUF model support, bash permission fixes in TUI, and add local/ollama switching/deleting support in web UI
- 5fa79fa: Renamed compression to compaction, added context-awareness to hono, updated cli tool display formatting and added integration test for image-local.
- ef40e60: Upgrades package versions and related changes to MCP SDK. CLI colors improved and token streaming added to status bar.
Security: Resolve all Dependabot security vulnerabilities. Updated @modelcontextprotocol/sdk to 1.25.2, esbuild to 0.25.0, langchain to 0.3.37, and @langchain/core to 0.3.80. Added pnpm overrides for indirect vulnerabilities (preact@10.27.3, qs@6.14.1, jws@3.2.3, mdast-util-to-hast@13.2.1). Fixed type errors from MCP SDK breaking changes.
- 436a900: Add support for openrouter, bedrock, glama, vertex ai, fix model switching issues and new model experience for each
- Updated dependencies [abfe5ce]
- Updated dependencies [ee12727]
- Updated dependencies [1e7e974]
- Updated dependencies [4c05310]
- Updated dependencies [5fa79fa]
- Updated dependencies [263fcc6]
- Updated dependencies [ef40e60]
- Updated dependencies [e714418]
- Updated dependencies [e7722e5]
- Updated dependencies [7d5ab19]
- Updated dependencies [436a900]
- @dexto/analytics@1.5.0
- @dexto/agent-management@1.5.0
- @dexto/server@1.5.0
- @dexto/core@1.5.0
- @dexto/image-local@1.5.0
- @dexto/registry@1.5.0
## 1.4.0
### Minor Changes
- f73a519: Revamp CLI. Breaking change to DextoAgent.generate() and stream() apis and hono message APIs, so new minor version. Other fixes for logs, web UI related to message streaming/generating
### Patch Changes
- a293c1a: Moved discord and telegram from CLI to examples.
- 7a64414: Updated agent-management to use AgentManager instead of AgentOrchestrator.
- 3cdce89: Revamp CLI for coding agent, add new events, improve mcp management, custom models, minor UI changes, prompt management
- d640e40: Remove LLM services, tokenizers, just stick with vercel, remove 'router' from schema and all types and docs
- 6f5627d: - Approval timeouts are now optional, defaulting to no timeout (infinite wait)
- Tool call history now includes success/failure status tracking
- 6e6a3e7: Fix message typings to use proper discriminated unions in core and webui
- c54760f: Revamp context management layer - add partial stream cancellation, message queueing, context compression with LLM, MCP UI support and gaming agent. New APIs and UI changes for these things
- 3b4b919: Fixed Ink CLI bugs and updated state management system.
- Updated dependencies [bd5c097]
- Updated dependencies [7a64414]
- Updated dependencies [3cdce89]
- Updated dependencies [d640e40]
- Updated dependencies [6f5627d]
- Updated dependencies [6e6a3e7]
- Updated dependencies [f73a519]
- Updated dependencies [c54760f]
- Updated dependencies [ab47df8]
- Updated dependencies [3b4b919]
- @dexto/core@1.4.0
- @dexto/agent-management@1.4.0
- @dexto/server@1.4.0
- @dexto/registry@1.4.0
- @dexto/analytics@1.4.0
## 1.3.0
### Minor Changes
- eb266af: Migrate WebUI from next-js to vite. Fix any typing in web UI. Improve types in core. minor renames in event schemas
### Patch Changes
- e2f770b: Add changeset for updated schema defaults and updated docs.
- 306f5de: Fix cursor navigation in CLI input. Users can now use left/right arrow keys, Home/End keys to navigate within the input text. Fixed by replacing CustomInput with CustomTextInput which uses ink-text-input with built-in cursor support.
- 6886db4: Add workflow builder/n8n agent and product analysis/posthog agent
- 66ce8c2: Added changeset for ink-cli upgrades and metadata patch in webui
- f843b62: Change otel and storage deps to peer dependencies with dynamic imports to reduce bloat
- Updated dependencies [e2f770b]
- Updated dependencies [f843b62]
- Updated dependencies [eb266af]
- @dexto/core@1.3.0
- @dexto/server@1.3.0
- @dexto/agent-management@1.3.0
- @dexto/analytics@1.3.0
## 1.2.6
### Patch Changes
- 7feb030: Update memory and prompt configs, fix agent install bug
- Updated dependencies [7feb030]
- @dexto/server@1.2.6
- @dexto/core@1.2.6
- @dexto/agent-management@1.2.6
- @dexto/analytics@1.2.6
## 1.2.5
### Patch Changes
- c1e814f: ## Logger v2 & Config Enrichment
### New Features
- **Multi-transport logging system**: Configure console, file, and remote logging transports via `logger` field in agent.yml. Supports log levels (error, warn, info, debug, silly) and automatic log rotation for file transports.
- **Per-agent isolation**: CLI automatically creates per-agent log files at `~/.dexto/logs/<agent-id>.log`, database at `~/.dexto/database/<agent-id>.db`, and blob storage at `~/.dexto/blobs/<agent-id>/`
- **Agent ID derivation**: Agent ID is now automatically derived from `agentCard.name` (sanitized) or config filename, enabling proper multi-agent isolation without manual configuration
### Breaking Changes
- **Storage blob default changed**: Default blob storage type changed from `local` to `in-memory`. Existing configs with explicit `blob: { type: 'local' }` are unaffected. CLI enrichment provides automatic paths for SQLite and local blob storage.
### Improvements
- **Config enrichment layer**: New `enrichAgentConfig()` in agent-management package adds per-agent paths before initialization, eliminating path resolution in core services
- **Logger error factory**: Added typed error factory pattern for logger errors following project conventions
- **Removed wildcard exports**: Logger module now uses explicit named exports for better tree-shaking
### Documentation
- Added complete logger configuration section to agent.yml documentation
- Documented agentId field and derivation rules
- Updated storage documentation with CLI auto-configuration notes
- Added logger v2 architecture notes to core README
- f9bca72: Add changeset for dropping defaultSessions from core layers.
- c0a10cd: Add changeset for mcp http mode patches.
- 81598b5: Decoupled elicitation from tool confirmation. Added `DenialReason` enum and structured error messages to approval responses.
- Tool approvals and elicitation now independently configurable via `elicitation.enabled` config
- Approval errors include `reason` (user_denied, timeout, system_denied, etc.) and `message` fields
- Enables `auto-approve` for tools while preserving interactive elicitation
Config files without the new `elicitation` section will use defaults. No legacy code paths.
- 1a20506: update source context usage to also go through preferences + registry flow. added dexto_dev_mode flag for maintainers
- 7bca27d: Added changeset for new react ink based CLI.
- 8f373cc: Migrate server API to Hono framework with feature flag
- Migrated Express server to Hono with OpenAPI schema generation
- Added DEXTO_USE_HONO environment variable flag (default: false for backward compatibility)
- Fixed WebSocket test isolation by adding sessionId filtering
- Fixed logger context to pass structured objects instead of stringified JSON
- Fixed CI workflow for OpenAPI docs synchronization
- Updated documentation links and fixed broken API references
- a35a256: Migrate from WebSocket to Server-Sent Events (SSE) for real-time streaming
- Replace WebSocket with SSE for message streaming via new `/api/message-stream` endpoint
- Refactor approval system from event-based providers to simpler handler pattern
- Add new APIs for session approval
- Move session title generation to a separate API
- Add `ApprovalCoordinator` for multi-client SSE routing with sessionId mapping
- Add stream and generate methods to DextoAgent and integ tests for itq=
- cc49f06: Added comprehensive support for A2A protocol
- a154ae0: UI refactor with TanStack Query, new agent management package, and Hono as default server
**Server:**
- Make Hono the default API server (use `DEXTO_USE_EXPRESS=true` env var to use Express)
- Fix agentId propagation to Hono server for correct agent name display
- Fix circular reference crashes in error logging by using structured logger context
**WebUI:**
- Integrate TanStack Query for server state management with automatic caching and invalidation
- Add centralized query key factory and API client with structured error handling
- Replace manual data fetching with TanStack Query hooks across all components
- Add Zustand for client-side persistent state (recent agents in localStorage)
- Add keyboard shortcuts support with react-hotkeys-hook
- Add optimistic updates for session management via WebSocket events
- Fix Dialog auto-close bug in CreateMemoryModal
- Add defensive null handling in MemoryPanel
- Standardize Prettier formatting (single quotes, 4-space indentation)
**Agent Management:**
- Add `@dexto/agent-management` package for centralized agent configuration management
- Extract agent registry, preferences, and path utilities into dedicated package
**Internal:**
- Improve build orchestration and fix dependency imports
- Add `@dexto/agent-management` to global CLI installation
- 5a26bdf: Update hono server to chain apis to keep type info, update client sdk to be fully typed
- Updated dependencies [c1e814f]
- Updated dependencies [f9bca72]
- Updated dependencies [c0a10cd]
- Updated dependencies [81598b5]
- Updated dependencies [4c90ffe]
- Updated dependencies [1a20506]
- Updated dependencies [8f373cc]
- Updated dependencies [f28ad7e]
- Updated dependencies [4dd4998]
- Updated dependencies [5e27806]
- Updated dependencies [a35a256]
- Updated dependencies [0fa6ef5]
- Updated dependencies [e2fb5f8]
- Updated dependencies [cc49f06]
- Updated dependencies [a154ae0]
- Updated dependencies [5a26bdf]
- Updated dependencies [ac649fd]
- @dexto/agent-management@1.2.5
- @dexto/server@1.2.5
- @dexto/core@1.2.5
- @dexto/analytics@1.2.5
## 1.2.4
### Patch Changes
- cd706e7: bump up version after fixing node-machine-id
- Updated dependencies [cd706e7]
- @dexto/analytics@1.2.4
- @dexto/core@1.2.4
## 1.2.3
### Patch Changes
- 5d6ae73: Bump up version to fix bugs
- Updated dependencies [5d6ae73]
- @dexto/analytics@1.2.3
- @dexto/core@1.2.3
## 1.2.2
### Patch Changes
- 8b96b63: Add posthog analytics package and add to web ui
- Updated dependencies [8b96b63]
- @dexto/analytics@1.2.2
- @dexto/core@1.2.2
## 1.2.1
### Patch Changes
- 9a26447: Fix starter prompts
- @dexto/core@1.2.1
## 1.2.0
### Minor Changes
- 1e25f91: Update web UI to be default, fix port bugs, update docs
### Patch Changes
- 4a191d2: Update agents
- a27ddf0: Add OTEL telemetry to trace agent execution
- 3a65cde: Update older LLMs to new LLMs, update docs
- 5ba5d38: **Features:**
- Agent switcher now supports file-based agents loaded via CLI (e.g., `dexto --agent path/to/agent.yml`)
- Agent selector UI remembers recent agents (up to 5) with localStorage persistence
- WebUI displays currently active file-based agent and recent agent history
- Dev server (`pnpm dev`) now auto-opens browser when WebUI is ready
- Added `/test-api` custom command for automated API test coverage analysis
**Bug Fixes:**
- Fixed critical bug where Memory, A2A, and MCP API routes used stale agent references after switching
- Fixed telemetry shutdown blocking agent switches when observability infrastructure (Jaeger/OTLP) is unavailable
- Fixed dark mode styling issues when Chrome's Auto Dark Mode is enabled
- Fixed agent card not updating for A2A and MCP routes after agent switch
**Improvements:**
- Refactored `Dexto.createAgent()` to static method, removing unnecessary singleton pattern
- Improved error handling for agent switching with typed errors (CONFLICT error type, `AgentError.switchInProgress()`)
- Telemetry now disabled by default (opt-in) in default agent configuration
- Added localStorage corruption recovery for recent agents list
- c6594d9: Add changeset for updated readme and docs.
- 3f99854: Update docs, update agents to use newer LLMs, update readmes
- 930a4ca: Fixes in UI, docs and agents
- 2940fbf: Add changeset for playground ui updates
- 930d75a: Add mcp server restart feature and button in webUI
- Updated dependencies [b51e4d9]
- Updated dependencies [a27ddf0]
- Updated dependencies [155813c]
- Updated dependencies [1e25f91]
- Updated dependencies [3a65cde]
- Updated dependencies [5ba5d38]
- Updated dependencies [930a4ca]
- Updated dependencies [ecad345]
- Updated dependencies [930d75a]
- @dexto/core@1.2.0
## 1.1.11
### Patch Changes
- c40b675: - Updated toolResult sanitization flow
- Added support for video rendering to WebUI
- 015100c: Added new memory manager for creating, storing and managing memories.
- FileContributor has a new memories contributor for loading memories into SystemPrompt.
- 2b81734: Updated WebUI for MCP connection flow and other minor updates
- 5cc6933: Fixes for prompts/resource management, UI improvements, custom slash command support, add support for embedded/linked resources, proper argument handling for prompts
- 40f89f5: Add New Agent buttons, form editor, new APIs, Dexto class
- 3a24d08: Add claude haiku 4.5 support
- 0558564: Several enhancement have been made to the WebUI to improve UI/UX
- Minor fixes to styling for AgentSelector and MCP Registry
- Sessions Panel updated UI & list ordering
- Sessions Panel updated page routing
- 01167a2: Refactors
- a53b87a: feat: Redesign agent registry system with improved agent switching
- **@dexto/core**: Enhanced agent registry with better ID-based resolution, improved error handling, and normalized registry entries
- **dexto**: Added agent switching capabilities via API with proper state management
- **@dexto/webui**: Updated agent selector UI with better UX for switching between agents
- Agent resolution now uses `agentId` instead of `agentName` throughout the system
- Registry entries now require explicit `id` field matching the registry key
- 24e5093: Add customize agent capabilities
- c695e57: Add blob storage system for persistent binary data management:
- Implement blob storage backend with local filesystem support
- Add blob:// URI scheme for referencing stored blobs
- Integrate blob storage with resource system for seamless @resource references
- Add automatic blob expansion in chat history and message references
- Add real-time cache invalidation events for resources and prompts
- Fix prompt cache invalidation WebSocket event handling in WebUI
- Add robustness improvements: empty text validation after resource expansion and graceful blob expansion error handling
- Support image/file uploads with automatic blob storage
- Add WebUI components for blob resource display and autocomplete
- 0a5636c: Added a new Approval System and support MCP Elicitations
- 35d48c5: Add chat summary generation
- Updated dependencies [c40b675]
- Updated dependencies [015100c]
- Updated dependencies [0760f8a]
- Updated dependencies [5cc6933]
- Updated dependencies [40f89f5]
- Updated dependencies [3a24d08]
- Updated dependencies [01167a2]
- Updated dependencies [a53b87a]
- Updated dependencies [24e5093]
- Updated dependencies [c695e57]
- Updated dependencies [0700f6f]
- Updated dependencies [0a5636c]
- Updated dependencies [35d48c5]
- @dexto/core@1.1.11
## 1.1.10
### Patch Changes
- 0f4d181: Add skip setup flag
- @dexto/core@1.1.10
## 1.1.9
### Patch Changes
- 27778ba: Add claude 4.5 sonnet and make it default
- Updated dependencies [27778ba]
- @dexto/core@1.1.9
## 1.1.8
### Patch Changes
- 35d82cc: Add github agent and update registry
- d79d358: Add agent toggle functionality to webui
- d79d358: Add new functions for agent management to DextoAgent()
- Updated dependencies [d79d358]
- @dexto/core@1.1.8
## 1.1.7
### Patch Changes
- 4216e79: Add auto approve flag
- @dexto/core@1.1.7
## 1.1.6
### Patch Changes
- e6d029c: Update logos and fix build
- @dexto/core@1.1.6
## 1.1.5
### Patch Changes
- 11cbec0: Update READMEs and docs
- dca7985: Simplify CLI session management with -c/-r flags and streamlined session commands
- Add -c/--continue flag to resume most recent session
- Add -r/--resume <sessionId> flag to resume specific session
- Remove redundant session commands (new, switch, current)
- Update default behavior to create new sessions
- Simplify help text and command descriptions
- 9d7541c: Add posthog telemetry
- Updated dependencies [e2bd0ce]
- Updated dependencies [11cbec0]
- Updated dependencies [795c7f1]
- Updated dependencies [9d7541c]
- @dexto/core@1.1.5
## 1.1.4
### Patch Changes
- de49328: Update dependencies
- 2fccffd: Migrating to monorepo
- Updated dependencies [2fccffd]
- @dexto/core@1.1.4

188
dexto/packages/cli/CLI.md Normal file
View File

@@ -0,0 +1,188 @@
# Dexto CLI Documentation
The Dexto CLI provides an interactive terminal interface for conversing with AI agents, managing sessions, switching models, and executing commands.
## Quick Start
```bash
# Start web UI (default)
dexto
# Start interactive CLI
dexto --mode cli
# Run one-shot query
dexto "what is the current time"
# Run with specific agent
dexto --agent coding-agent --mode cli
```
## CLI Features
### Interactive Chat
- **Modern Terminal UI** - Built with Ink for a responsive, chat-optimized interface
- **Real-time Streaming** - See AI responses as they're generated
- **Message History** - Scroll through conversation with up/down arrows
- **Auto-complete** - Slash commands and resource mentions with tab completion
### Slash Commands
Type `/` to see available commands:
- `/help` - Show available commands
- `/model` - Switch LLM model (interactive selector)
- `/session` - Manage chat sessions
- `/session list` - List all sessions
- `/session switch` - Switch to another session (interactive)
- `/session new` - Create new session
- `/session delete <id>` - Delete a session
- `/clear` - Clear conversation history
- `/exit` or `/quit` - Exit the CLI
### Resource References
Use `@` to reference files and resources:
- Type `@` at the start of input or after a space
- Autocomplete shows available resources from MCP servers
- Select with arrow keys and Enter
### Session Management
- **New session**: Each CLI launch starts fresh (first message creates new session)
- **Resume by ID**: `dexto --mode cli -r <session-id>` (resume specific session)
- **Resume interactively**: Use `/resume` or `/session switch` in CLI
- **Auto-save**: Sessions are automatically saved
- **Search history**: `dexto search <query>`
### Model Switching
```bash
# Switch model via command
dexto -m gpt-4o --mode cli
# Or use interactive selector in CLI
/model
```
### Keyboard Shortcuts
- **↑/↓** - Navigate input history
- **Esc** - Cancel current operation or close overlays
- **Ctrl+C** - Exit CLI (or cancel if processing)
- **Tab** - Autocomplete commands/resources
- **Enter** - Submit input or select autocomplete item
## Advanced Usage
### Tool Confirmation
```bash
# Auto-approve all tool executions
dexto --mode cli --auto-approve
# Manual approval (default)
dexto --mode cli
```
### Custom Agents
```bash
# Use agent by name
dexto --agent coding-agent --mode cli
# Use agent from file
dexto --agent ./my-agent.yml --mode cli
```
### Headless Mode
```bash
# One-shot query with output
dexto -p "list files in current directory"
# Resume a session and run query
dexto -r <session-id> -p "what did we discuss?"
# Piped input
cat document.txt | dexto -p "summarize this"
```
## Architecture
The CLI is built on a modern, maintainable architecture:
### Core Components
- **InkCLIRefactored** - Main orchestrator using React Ink
- **State Management** - Centralized reducer pattern for predictable state
- **Custom Hooks** - Reusable logic (events, history, shortcuts, overlays)
- **Services** - Business logic layer (commands, messages, input parsing)
- **Base Components** - Reusable UI primitives (selectors, autocomplete)
### Code Structure
```text
packages/cli/src/cli/ink-cli/
├── InkCLIRefactored.tsx # Main component
├── state/ # State management
├── hooks/ # Custom hooks
├── services/ # Business logic
├── components/ # UI components
│ ├── base/ # Reusable components
│ ├── chat/ # Chat UI
│ ├── input/ # Input area
│ └── overlays/ # Model/session selectors
├── containers/ # Smart containers
└── utils/ # Helper functions
```
## Troubleshooting
### CLI Not Starting
- Ensure terminal supports UTF-8 and ANSI colors
- Try setting `TERM=xterm-256color`
- Check that Node.js >= 20.0.0
### Autocomplete Not Working
- Make sure you're in interactive mode (not headless)
- Type `/` for commands or `@` for resources
- Arrow keys to navigate, Tab to load into input, Enter to select
### Session Not Found
- List sessions: `dexto session list`
- Sessions are stored in `~/.dexto/sessions/` (or repo `.dexto/` in dev mode)
- Use session ID from list output
### Model Not Available
- Check configured providers in agent config
- Ensure API keys are set in environment or `.env`
- Use `/model` to see available models
## Development
### Dev Mode
```bash
# Use repository configs (not global ~/.dexto)
export DEXTO_DEV_MODE=true
dexto --mode cli
```
### Hot Reload
```bash
# Build and run dev server
pnpm run dev
# Or just CLI
pnpm run build:cli
dexto --mode cli
```
### Testing
```bash
# Unit tests
pnpm run test:unit
# Integration tests
pnpm run test:integ
# All tests
pnpm test
```
## See Also
- [Main README](../../README.md) - Project overview
- [Development Guide](../../DEVELOPMENT.md) - Development workflows
- [Agent Configuration](../../agents/coding-agent/coding-agent.yml) - Default agent setup
- [Core Documentation](../core/README.md) - Core library reference

View File

@@ -0,0 +1,672 @@
<a href="https://dexto.ai">
<div align="center">
<picture>
<source media="(prefers-color-scheme: light)" srcset=".github/assets/dexto_logo_light.svg">
<source media="(prefers-color-scheme: dark)" srcset=".github/assets/dexto_logo_dark.svg">
<img alt="Dexto" src=".github/assets/dexto_logo_dark.svg" width="55%" style="max-width: 1000px; padding: 48px 8px;">
</picture>
</div>
</a>
<p align="center">
<img src="https://img.shields.io/badge/Status-Beta-yellow">
<img src="https://img.shields.io/badge/License-Elastic%202.0-blue.svg">
<a href="https://discord.gg/GFzWFAAZcm"><img src="https://img.shields.io/badge/Discord-Join%20Chat-7289da?logo=discord&logoColor=white"></a>
<a href="https://deepwiki.com/truffle-ai/dexto"><img src="https://deepwiki.com/badge.svg"></a>
</p>
<!-- Keep these links. Translations will automatically update with the README. -->
<p align="center">
<a href="https://zdoc.app/de/truffle-ai/dexto">Deutsch</a> |
<a href="https://zdoc.app/en/truffle-ai/dexto">English</a> |
<a href="https://zdoc.app/es/truffle-ai/dexto">Español</a> |
<a href="https://zdoc.app/fr/truffle-ai/dexto">français</a> |
<a href="https://zdoc.app/ja/truffle-ai/dexto">日本語</a> |
<a href="https://zdoc.app/ko/truffle-ai/dexto">한국어</a> |
<a href="https://zdoc.app/pt/truffle-ai/dexto">Português</a> |
<a href="https://zdoc.app/ru/truffle-ai/dexto">Русский</a> |
<a href="https://zdoc.app/zh/truffle-ai/dexto">中文</a>
</p>
<p align="center"><b>An open agent harness for AI applications—ships with a powerful coding agent.</b></p>
<div align="center">
<img src=".github/assets/dexto_title.gif" alt="Dexto Demo" width="600" />
</div>
---
## What is Dexto?
Dexto is an **agent harness**—the orchestration layer that turns LLMs into reliable, stateful agents that can take actions, remember context, and recover from errors.
Think of it like an operating system for AI agents:
| Component | Analogy | Role |
|-----------|---------|------|
| **LLM** | CPU | Raw processing power |
| **Context Window** | RAM | Working memory |
| **Dexto** | Operating System | Orchestration, state, tools, recovery |
| **Your Agent** | Application | Domain-specific logic and clients |
### Why Dexto?
- **Configuration-driven**: Define agents in YAML. Swap models and tools without touching code.
- **Batteries included**: Session management, tool orchestration, memory, multimodal support, and observability—out of the box.
- **Run anywhere**: Local, cloud, or hybrid. CLI, Web UI, REST API, Discord, Telegram, or embedded in your app.
### What You Can Build
- **Coding Agents** Build, debug, and refactor code autonomously
- **Autonomous Agents** Plan, execute, and adapt to user goals
- **Digital Companions** Assistants that remember context and anticipate needs
- **MCP Clients & Servers** Connect tools, files, APIs via Model Context Protocol
- **Multi-Agent Systems** Agents that collaborate, delegate, and solve complex tasks together
---
## Coding Agent
Dexto ships with a **production-ready coding agent** you can use immediately via the CLI or Web UI.
```bash
# Launch the coding agent (default)
dexto
# Or explicitly
dexto --agent coding-agent
```
**What it can do:**
- Build new apps from scratch
- Read, write, and refactor code across your entire codebase
- Execute shell commands and run tests
- Spawn specialized sub-agents for exploration and planning
- Remember context across sessions with persistent memory
- Work with any of 50+ LLMs (swap models mid-conversation)
**Ready-to-use interfaces:**
- **Web UI** Chat interface with file uploads, syntax highlighting, and MCP tool browser
- **CLI** Terminal-native with `/commands`, streaming output, and session management
The coding agent is just one example of what you can build. Create your own agents by defining a YAML config—same architecture, your domain.
---
## Quick Start
### Install
```bash
# Install globally via npm
npm install -g dexto
# Or build from source
git clone https://github.com/truffle-ai/dexto.git
cd dexto && pnpm install && pnpm install-cli
```
### Run
```bash
# Start Dexto (launches setup wizard on first run)
dexto
```
**More options:**
```bash
dexto --mode cli # Terminal mode
dexto -p "create a landing page for a coffee shop" # One-shot task
dexto --auto-approve "refactor this codebase" # Skip confirmations
dexto -m claude-sonnet-4-5-20250929 # Switch models
dexto --help # Explore all options
```
**Inside the interactive CLI**, type `/` to explore commands—switch models, manage sessions, configure tools, and more.
### Manage Settings
```bash
# Configure defaults like LLM provider/model, API keys, or download local models
dexto setup
```
Logs are stored in `~/.dexto/logs/`. Use `DEXTO_LOG_LEVEL=debug` for verbose output.
---
## Core Features
### 50+ LLMs, Instant Switching
Switch models mid-conversation—no code changes, no restarts.
| Provider | Models |
|----------|--------|
| **OpenAI** | gpt-5.2, gpt-5.2-pro, gpt-5.2-codex, o4-mini |
| **Anthropic** | Claude Sonnet, Opus, Haiku (with extended thinking) |
| **Google** | Gemini 3 Pro, 2.5 Pro/Flash |
| **Groq** | Llama 4, Qwen, DeepSeek |
| **xAI** | Grok 4, Grok 3 |
| **Local** | Ollama, GGUF via node-llama-cpp (Llama, Qwen, Mistral, etc.) |
| **+ Gateways** | OpenRouter, AWS Bedrock, Vertex AI, LiteLLM |
**Run locally for privacy**: Local models keep data on your machine with automatic GPU detection (Metal, CUDA, Vulkan).
### MCP Integration (30+ Tools)
Connect to Model Context Protocol servers—Puppeteer, Linear, ElevenLabs, Firecrawl, Sora, and more.
```yaml
# agents/my-agent.yml
mcpServers:
filesystem:
type: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-filesystem', '.']
browser:
type: stdio
command: npx
args: ['-y', '@anthropics/mcp-server-puppeteer']
```
Browse and add servers from the MCP Store in the Web UI or via `/mcp` commands in the CLI.
### Human-in-the-Loop Controls
Fine-grained control over what your agent can do:
```yaml
toolConfirmation:
mode: manual # Require approval for each tool
# mode: auto-approve # Trust mode for local development
toolPolicies:
alwaysAllow:
- mcp--filesystem--read_file
- mcp--filesystem--list_directory
alwaysDeny:
- mcp--filesystem--delete_file
```
Agents remember which tools you've approved per session.
### Persistent Sessions & Memory
Conversations persist across restarts. Create memories that shape agent behavior.
```bash
# Continue last conversation
dexto -c
# Resume specific session
dexto -r session-abc123
# Search across all conversations
dexto search "database schema"
```
### Multi-Agent Systems
Agents can spawn specialized sub-agents to handle complex subtasks. The coding agent uses this to delegate exploration:
```yaml
# In your agent config
customTools:
- type: agent-spawner
allowedAgents: ["explore-agent"]
maxConcurrentAgents: 5
defaultTimeout: 300000 # 5 minutes
```
**Built-in sub-agents:**
- **explore-agent** Fast, read-only codebase exploration
Any agent in the [Agent Registry](#agent-registry) can be spawned as a sub-agent—including custom agents you create and register.
Sub-agents run ephemerally, auto-cleanup after completion, and forward tool approvals to the parent—so users see one unified approval flow.
---
## Run Modes
| Mode | Command | Use Case |
|------|---------|----------|
| **Web UI** | `dexto` | Chat interface with file uploads (default) |
| **CLI** | `dexto --mode cli` | Terminal interaction |
| **Web Server** | `dexto --mode server` | REST & SSE APIs |
| **MCP Server** | `dexto --mode mcp` | Expose agent as an MCP server over stdio |
Platform integrations: [Discord](examples/discord-bot/), [Telegram](examples/telegram-bot/)
<details>
<summary><strong>Dexto as an MCP Server</strong></summary>
**Transport: `stdio`**
Connect your Dexto agent as an MCP server in Claude Code or Cursor:
```bash
dexto --mode mcp --auto-approve --no-elicitation # Default agent
dexto --mode mcp --agent coding-agent --auto-approve --no-elicitation
npx dexto --mode mcp --agent coding-agent --auto-approve --no-elicitation
```
Example MCP config for Claude Code or Cursor:
```json
{
"command": "npx",
"args": ["-y", "dexto", "--mode", "mcp", "--agent", "coding-agent", "--auto-approve", "--no-elicitation"]
}
```
`--auto-approve` and `--no-elicitation` are required for MCP mode since it runs non-interactively.
**Transport: `http/sse`**
`dexto --mode server` exposes your agent as an MCP server at `/mcp` for remote clients:
```bash
dexto --mode server --port 3001
ngrok http 3001 # Optional: expose publicly
```
</details>
---
## Dexto Agents SDK
Build AI agents programmatically. Everything the CLI does, your code can too.
```bash
npm install @dexto/core
```
```typescript
import { DextoAgent } from '@dexto/core';
const agent = new DextoAgent({
llm: { provider: 'openai', model: 'gpt-5.2', apiKey: process.env.OPENAI_API_KEY }
});
await agent.start();
const session = await agent.createSession();
const response = await agent.generate('What is TypeScript?', session.id);
console.log(response.content);
// Streaming
for await (const event of await agent.stream('Write a story', session.id)) {
if (event.name === 'llm:chunk') process.stdout.write(event.content);
}
// Multimodal
await agent.generate([
{ type: 'text', text: 'Describe this image' },
{ type: 'image', image: base64Data, mimeType: 'image/png' }
], session.id);
// Switch models mid-conversation
await agent.switchLLM({ model: 'claude-sonnet-4-5-20250929' });
```
**Start a server programmatically:**
Start a Dexto server programmatically to expose REST and SSE streaming APIs to interact and manage your agent backend.
```typescript
import { DextoAgent } from '@dexto/core';
import { loadAgentConfig, startHonoApiServer } from 'dexto';
const config = await loadAgentConfig('./agents/my-agent.yml');
const agent = new DextoAgent(config);
const { server } = await startHonoApiServer(agent, 3001);
// POST /api/message, GET /api/sessions, etc.
```
This starts an HTTP server with full REST and SSE APIs, enabling integration with web frontends, webhooks, and other services. See the [REST API Documentation](https://docs.dexto.ai/api/rest/) for available endpoints.
<details>
<summary><strong>Advanced SDK Usage</strong></summary>
### Session Management
Create and manage multiple conversation sessions with persistent storage.
```typescript
const agent = new DextoAgent(config);
await agent.start();
// Create and manage sessions
const session = await agent.createSession('user-123');
await agent.generate('Hello, how can you help me?', session.id);
// List and manage sessions
const sessions = await agent.listSessions();
const history = await agent.getSessionHistory('user-123');
await agent.deleteSession('user-123');
// Search across conversations
const results = await agent.searchMessages('bug fix', { limit: 10 });
```
### LLM Management
Switch between models and providers dynamically.
```typescript
// Get current configuration
const currentLLM = agent.getCurrentLLMConfig();
// Switch models (provider inferred automatically)
await agent.switchLLM({ model: 'gpt-5.2' });
await agent.switchLLM({ model: 'claude-sonnet-4-5-20250929' });
// Switch model for a specific session
await agent.switchLLM({ model: 'gpt-5.2' }, 'session-123');
// Get supported providers and models
const providers = agent.getSupportedProviders();
const models = agent.getSupportedModels();
```
### MCP Manager
For advanced MCP server management, use the MCPManager directly.
```typescript
import { MCPManager } from '@dexto/core';
const manager = new MCPManager();
// Connect to MCP servers
await manager.connectServer('filesystem', {
type: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '.']
});
// Access tools, prompts, and resources
const tools = await manager.getAllTools();
const prompts = await manager.getAllPrompts();
const resources = await manager.getAllResources();
// Execute tools
const result = await manager.executeTool('readFile', { path: './README.md' });
await manager.disconnectAll();
```
### Storage & Persistence
Configure storage backends for production.
```yaml
# agents/production-agent.yml
storage:
cache:
type: redis
url: $REDIS_URL
database:
type: postgres
connectionString: $POSTGRES_CONNECTION_STRING
sessions:
maxSessions: 1000
sessionTTL: 86400000 # 24 hours
```
**Supported Backends:**
- **Cache**: Redis, In-Memory
- **Database**: PostgreSQL, SQLite, In-Memory
See the [Storage Configuration guide](https://docs.dexto.ai/docs/guides/configuring-dexto/storage) for details.
</details>
---
## Agent Registry
Pre-built agents for common use cases:
```bash
# List available agents
dexto list-agents
# Install and run
dexto install coding-agent podcast-agent
dexto --agent coding-agent
```
**Available:** Coding, Podcast, Image Editor, Video (Sora), Database, GitHub, Triage (multi-agent), and more.
See the full [Agent Registry](https://docs.dexto.ai/docs/guides/agent-registry).
---
## Agent Configuration
Dexto treats each configuration as a unique agent allowing you to define and save combinations of LLMs, servers, storage options, etc. based on your needs for easy portability. Define agents in version-controlled YAML. Change the file, reload, and chat—state, memory, and tools update automatically.
```yaml
# agents/production-agent.yml
llm:
provider: anthropic
model: claude-sonnet-4-5-20250929
apiKey: $ANTHROPIC_API_KEY
mcpServers:
filesystem:
type: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-filesystem', '.']
systemPrompt: |
You are a helpful assistant with filesystem access.
storage:
cache:
type: redis
url: $REDIS_URL
database:
type: postgres
connectionString: $POSTGRES_CONNECTION_STRING
toolConfirmation:
mode: manual
```
### LLM Providers
Switch between providers instantly—no code changes required.
#### Built-in Providers
| Provider | Models | Setup |
|----------|--------|-------|
| **OpenAI** | `gpt-5.2`, `gpt-5.2-pro`, `gpt-5.2-codex`, `o4-mini` | API key |
| **Anthropic** | `claude-sonnet-4-5-20250929`, `claude-opus-4-5-20250929`, extended thinking | API key |
| **Google** | `gemini-3-pro`, `gemini-2.5-pro`, `gemini-2.5-flash` | API key |
| **Groq** | `llama-4-scout`, `qwen-qwq`, `deepseek-r1-distill` | API key |
| **xAI** | `grok-4`, `grok-3`, `grok-3-fast` | API key |
| **Cohere** | `command-r-plus`, `command-r` | API key |
#### Local Models (Privacy-First)
| Provider | Models | Setup |
|----------|--------|-------|
| **Ollama** | Llama, Qwen, Mistral, DeepSeek, etc. | Local install |
| **node-llama-cpp** | Any GGUF model | Bundled (auto GPU detection: Metal, CUDA, Vulkan) |
#### Cloud Platforms
| Provider | Models | Setup |
|----------|--------|-------|
| **AWS Bedrock** | Claude, Llama, Mistral | AWS credentials |
| **Google Vertex AI** | Gemini, Claude | GCP credentials |
#### Gateway Providers
| Provider | Access | Setup |
|----------|--------|-------|
| **OpenRouter** | 100+ models from multiple providers | API key |
| **LiteLLM** | Unified API for any provider | Self-hosted or API key |
| **Glama** | Multi-provider gateway | API key |
```bash
# Switch models via CLI
dexto -m claude-sonnet-4-5-20250929
dexto -m gemini-2.5-pro
dexto -m llama-4-scout
```
Switch within interactive CLI (`/model`) or Web UI without config changes.
See the [Configuration Guide](https://docs.dexto.ai/docs/category/agent-configuration-guide).
---
## Demos & Examples
| Image Editor | MCP Store | Portable Agents |
|:---:|:---:|:---:|
| <img src=".github/assets/image_editor_demo.gif" alt="Image Editor" width="280"/> | <img src=".github/assets/mcp_store_demo.gif" alt="MCP Store" width="280"/> | <img src=".github/assets/portable_agent_demo.gif" alt="Portable Agents" width="280"/> |
| Face detection & annotation using OpenCV | Browse and add MCPs | Use agents in Cursor, Claude Code via MCP|
<details>
<summary><strong>More Examples</strong></summary>
### Coding Agent
Build applications from natural language:
```bash
dexto --agent coding-agent
# "Create a snake game and open it in the browser"
```
<img src=".github/assets/coding_agent_demo.gif" alt="Coding Agent Demo" width="600"/>
### Podcast Agent
Generate multi-speaker audio content:
```bash
dexto --agent podcast-agent
```
<img src="https://github.com/user-attachments/assets/cfd59751-3daa-4ccd-97b2-1b2862c96af1" alt="Podcast Agent Demo" width="600"/>
### Multi-Agent Triage
Coordinate specialized agents:
```bash
dexto --agent triage-agent
```
<img src=".github/assets/triage_agent_demo.gif" alt="Triage Agent Demo" width="600">
### Memory System
Persistent context that shapes behavior:
<img src=".github/assets/memory_demo.gif" alt="Memory Demo" width="600">
### Dynamic Forms
Agents generate forms for structured input:
<img src=".github/assets/user_form_demo.gif" alt="User Form Demo" width="600">
### Browser Automation
<a href="https://youtu.be/C-Z0aVbl4Ik">
<img src="https://github.com/user-attachments/assets/3f5be5e2-7a55-4093-a071-8c52f1a83ba3" alt="Amazon Shopping Demo" width="600"/>
</a>
### MCP Playground
Test tools before deploying:
<img src=".github/assets/playground_demo.gif" alt="Playground Demo" width="600">
</details>
---
<details>
<summary><strong>CLI Reference</strong></summary>
```text
Usage: dexto [options] [command] [prompt...]
Basic Usage:
dexto Start web UI (default)
dexto "query" Run one-shot query
dexto --mode cli Interactive CLI
Session Management:
dexto -c Continue last conversation
dexto -r <id> Resume specific session
Options:
-a, --agent <path> Agent config file or ID
-m, --model <model> LLM model to use
--auto-approve Skip tool confirmations
--no-elicitation Disable elicitation prompts
--mode <mode> web | cli | server | mcp
--port <port> Server port
Commands:
setup Configure global preferences
install <agents...> Install agents from registry
list-agents List available agents
session list|history Manage sessions
search <query> Search conversation history
```
Full reference: `dexto --help`
</details>
---
## Documentation
- **[Quick Start](https://docs.dexto.ai/docs/getting-started/intro/)** Get running in minutes
- **[Configuration Guide](https://docs.dexto.ai/docs/category/guides/)** Agents, LLMs, tools
- **[SDK Reference](https://docs.dexto.ai/api/sdk/dexto-agent)** Programmatic usage
- **[REST API](https://docs.dexto.ai/api/rest/)** HTTP endpoints
---
## Telemetry
We collect anonymous usage data (no personal/sensitive info) to help improve Dexto. This includes:
- Commands used
- Command execution time
- Error occurrences
- System information (OS, Node version)
- LLM Models used
To opt-out:
Set env variable `DEXTO_ANALYTICS_DISABLED=1`
---
## Contributing
See [CONTRIBUTING.md](https://github.com/truffle-ai/dexto/blob/HEAD/CONTRIBUTING.md).
---
## Community
Built by [Truffle AI](https://www.trytruffle.ai). Join [Discord](https://discord.gg/GFzWFAAZcm) for support.
If you find Dexto useful, please give us a ⭐ on GitHub—it helps a lot!
[![Twitter Follow](https://img.shields.io/twitter/follow/Dexto?style=social)](https://x.com/intent/user?screen_name=dexto_ai)
[![Twitter Follow](https://img.shields.io/twitter/follow/Rahul?style=social)](https://x.com/intent/user?screen_name=Road_Kill11)
[![Twitter Follow](https://img.shields.io/twitter/follow/Shaunak?style=social)](https://x.com/intent/user?screen_name=shaun5k_)
---
## Contributors
[![Contributors](https://contrib.rocks/image?repo=truffle-ai/dexto)](https://github.com/truffle-ai/dexto/graphs/contributors)
---
## License
Elastic License 2.0. See [LICENSE](https://github.com/truffle-ai/dexto/blob/HEAD/LICENSE).

View File

@@ -0,0 +1,73 @@
{
"name": "dexto",
"version": "1.5.6",
"type": "module",
"bin": {
"dexto": "./dist/index.js"
},
"dependencies": {
"@clack/prompts": "^0.10.1",
"@dexto/agent-management": "workspace:*",
"@dexto/analytics": "workspace:*",
"@dexto/core": "workspace:*",
"@dexto/image-local": "workspace:*",
"@dexto/registry": "workspace:*",
"@dexto/server": "workspace:*",
"@modelcontextprotocol/sdk": "^1.25.2",
"@opentelemetry/instrumentation-http": "^0.210.0",
"@opentelemetry/instrumentation-undici": "^0.20.0",
"@opentelemetry/core": "^1.28.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/resources": "^1.28.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/sdk-trace-base": "^1.28.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"better-sqlite3": "^11.10.0",
"boxen": "^7.1.1",
"chalk": "^5.6.0",
"cli-highlight": "^2.1.11",
"commander": "^11.1.0",
"diff": "^8.0.2",
"dotenv": "^16.4.7",
"fs-extra": "^11.3.0",
"hono": "^4.7.13",
"ink": "npm:@jrichman/ink@6.4.6",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"ioredis": "^5.7.0",
"open": "^10.2.0",
"pg": "^8.15.4",
"posthog-node": "^4.2.1",
"react": "19.1.1",
"react-dom": "19.1.1",
"string-width": "^8.1.0",
"strip-ansi": "^7.1.2",
"tsx": "^4.19.2",
"wrap-ansi": "^9.0.2",
"ws": "^8.18.1",
"yaml": "^2.7.1",
"zod": "^3.25.0"
},
"scripts": {
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tsc -p tsconfig.json && pnpm run copy-agents && pnpm run copy-assets",
"_comment_build": "TODO: (355) This is a known issue with MCP SDK >= 1.18.1 taking heap space, see if we can solve this without heap size hacks - https://github.com/truffle-ai/dexto/pull/355#discussion_r2412949424",
"copy-agents": "tsx scripts/copy-agents.ts",
"copy-assets": "mkdir -p dist/cli/assets && cp -r src/cli/assets/* dist/cli/assets/",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
"lint": "eslint . --ext .ts"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/ws": "^8.5.11",
"type-fest": "^4.26.1"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env tsx
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Configuration - which agents and files to copy
const AGENTS_TO_COPY = [
// Core files
'agent-registry.json',
'agent-template.yml',
'default-agent.yml',
// Agent directories
'coding-agent/',
'database-agent/',
'explore-agent/',
'github-agent/',
'image-editor-agent/',
'music-agent/',
'nano-banana-agent/',
'podcast-agent/',
'product-name-researcher/',
'sora-video-agent/',
'talk2pdf-agent/',
'triage-demo/',
];
const SOURCE_DIR = join(__dirname, '../../../agents');
const DEST_DIR = join(__dirname, '../dist/agents');
/**
* Recursively copy a directory
*/
function copyDirectory(src: string, dest: string): void {
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
}
const entries = readdirSync(src);
for (const entry of entries) {
const srcPath = join(src, entry);
const destPath = join(dest, entry);
const stat = statSync(srcPath);
if (stat.isDirectory()) {
copyDirectory(srcPath, destPath);
} else {
copyFileSync(srcPath, destPath);
}
}
}
/**
* Copy a single file
*/
function copyFile(src: string, dest: string): void {
const destDir = dirname(dest);
if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true });
}
copyFileSync(src, dest);
}
/**
* Main copy function
*/
function copyAgents(): void {
console.log('📦 Copying configured agents to dist...');
// Ensure source directory exists
if (!existsSync(SOURCE_DIR)) {
console.error(`❌ Source directory not found: ${SOURCE_DIR}`);
process.exit(1);
}
// Create destination directory
if (!existsSync(DEST_DIR)) {
mkdirSync(DEST_DIR, { recursive: true });
}
let copiedCount = 0;
for (const item of AGENTS_TO_COPY) {
// Normalize the item: remove any trailing slash so path.join works consistently on all OSes
const normalizedItem = item.replace(/\/$/, '');
const srcPath = join(SOURCE_DIR, normalizedItem);
const destPath = join(DEST_DIR, normalizedItem);
if (!existsSync(srcPath)) {
console.warn(`⚠️ Skipping missing item: ${item}`);
continue;
}
const stat = statSync(srcPath);
if (stat.isDirectory()) {
console.log(`📁 Copying directory: ${normalizedItem}`);
copyDirectory(srcPath, destPath);
copiedCount++;
} else if (stat.isFile()) {
console.log(`📄 Copying file: ${normalizedItem}`);
copyFile(srcPath, destPath);
copiedCount++;
} else {
console.warn(`⚠️ Skipping non-regular entry: ${normalizedItem}`);
}
}
console.log(`✅ Successfully copied ${copiedCount}/${AGENTS_TO_COPY.length} agents to dist`);
}
// Run the script
if (import.meta.url === `file://${process.argv[1]}`) {
try {
copyAgents();
} catch (error) {
console.error('❌ Failed to copy agents:', error);
process.exit(1);
}
}

View File

@@ -0,0 +1,60 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve repo root from CLI scripts directory: packages/cli/scripts -> repo root
const repoRoot = path.resolve(__dirname, '../../..');
const srcPath = path.join(repoRoot, 'README.md');
// Destination is the CLI package README next to this scripts directory
const cliDir = path.resolve(__dirname, '..');
const destPath = path.join(cliDir, 'README.md');
const GH_BASE = 'https://github.com/truffle-ai/dexto';
const GH_BLOB_HEAD = `${GH_BASE}/blob/HEAD`;
const GH_TREE_HEAD = `${GH_BASE}/tree/HEAD`;
function transform(content: string): string {
// Change top-level H1
content = content.replace(/^#\s+[^\n]+/, '# Dexto CLI');
// Fix relative links to repo paths so they render on npm
content = content
// agents directory
.replace(/\]\(agents\/\)/g, `](${GH_TREE_HEAD}/agents/)`)
.replace(/\]\(agents\)/g, `](${GH_TREE_HEAD}/agents)`) // fallback
// discord/telegram setup docs
.replace(
/\]\(packages\/cli\/src\/discord\/README\.md\)/g,
`](${GH_BLOB_HEAD}/packages/cli/src/discord/README.md)`
)
.replace(
/\]\(packages\/cli\/src\/telegram\/README\.md\)/g,
`](${GH_BLOB_HEAD}/packages/cli/src/telegram/README.md)`
)
// contributor guide & license
.replace(/\]\(\.\/CONTRIBUTING\.md\)/g, `](${GH_BLOB_HEAD}/CONTRIBUTING.md)`)
.replace(/\]\(LICENSE\)/g, `](${GH_BLOB_HEAD}/LICENSE)`);
// Fix relative image to assets folder
content = content.replace(
/<img\s+src="assets\/email_slack_demo\.gif"/g,
`<img src="${GH_BLOB_HEAD}/assets/email_slack_demo.gif?raw=1"`
);
return content;
}
function main(): void {
const raw = fs.readFileSync(srcPath, 'utf8');
const out = transform(raw);
fs.writeFileSync(destPath, out);
console.log(
`Synced CLI README from ${path.relative(repoRoot, srcPath)} -> ${path.relative(repoRoot, destPath)}`
);
}
main();

View File

@@ -0,0 +1,27 @@
// packages/cli/src/analytics/constants.ts
// Single source for PostHog configuration.
// Embed your public key here (safe to publish). Host can stay default.
export const DEFAULT_POSTHOG_KEY = 'phc_IJHITHjBKOjDyFiVeilfdumcGniXMuLeXeiLQhYvwDW';
export const DEFAULT_POSTHOG_HOST = 'https://app.posthog.com';
/**
* Single opt-out switch for analytics.
*
* Usage:
* DEXTO_ANALYTICS_DISABLED=1 dexto ...
*
* When set to a truthy value ("1", "true", "yes"), analytics are fully disabled.
*/
export function isAnalyticsDisabled(): boolean {
const v = process.env.DEXTO_ANALYTICS_DISABLED;
return typeof v === 'string' && /^(1|true|yes)$/i.test(v);
}
/**
* Generic per-command timeout (in milliseconds) used by the analytics wrapper.
*
* This does NOT terminate the command. It emits a non-terminating timeout
* event when the duration threshold is crossed to help diagnose long runs.
*/
export const COMMAND_TIMEOUT_MS = 120000; // 2 minutes (default for quick commands)

View File

@@ -0,0 +1,145 @@
// packages/cli/src/analytics/events.ts
// Typed payload scaffolding for PostHog events emitted by the CLI.
// These types describe the additional properties supplied when we call
// `capture(event, properties)`. Base context (app, version, OS, execution context,
// session_id, etc.) is merged automatically in analytics/index.ts.
import type { ExecutionContext } from '@dexto/agent-management';
import type { SharedAnalyticsEventMap } from '@dexto/analytics';
export interface BaseEventContext {
app?: 'dexto';
app_version?: string;
node_version?: string;
os_platform?: NodeJS.Platform;
os_release?: string;
os_arch?: string;
execution_context?: ExecutionContext;
session_id?: string | null;
}
export interface CommandArgsMeta {
argTypes: string[];
positionalRaw?: string[];
positionalCount?: number;
optionKeys?: string[];
options?: Record<string, SanitizedOptionValue>;
}
export type SanitizedOptionValue =
| string
| number
| boolean
| null
| { type: 'array'; length: number }
| { type: 'object' };
export type CliCommandPhase = 'start' | 'end' | 'timeout';
interface CliCommandBaseEvent {
name: string;
phase: CliCommandPhase;
args?: CommandArgsMeta;
}
export interface CliCommandStartEvent extends CliCommandBaseEvent {
phase: 'start';
}
export interface CliCommandEndEvent extends CliCommandBaseEvent {
phase: 'end';
success: boolean;
durationMs: number;
error?: string;
reason?: string;
command?: string;
}
export interface CliCommandTimeoutEvent extends CliCommandBaseEvent {
phase: 'timeout';
timeoutMs: number;
}
export type CliCommandEvent = CliCommandStartEvent | CliCommandEndEvent | CliCommandTimeoutEvent;
export interface PromptEvent {
mode: 'cli' | 'headless';
provider: string;
model: string;
}
export interface SetupEvent {
provider: string;
model: string;
hadApiKeyBefore?: boolean;
setupMode: 'interactive' | 'non-interactive';
setupVariant?: 'quick-start' | 'custom' | 'dexto';
defaultMode?: string;
hasBaseURL?: boolean;
apiKeySkipped?: boolean;
}
export interface InstallAgentEvent {
agent: string;
status: 'installed' | 'skipped' | 'failed';
force: boolean;
reason?: string;
error_message?: string;
}
export interface InstallAggregateEvent {
requested: string[];
installed: string[];
skipped: string[];
failed: string[];
successCount: number;
errorCount: number;
}
export interface UninstallAgentEvent {
agent: string;
status: 'uninstalled' | 'failed';
force: boolean;
error_message?: string;
}
export interface UninstallAggregateEvent {
requested: string[];
uninstalled: string[];
failed: string[];
successCount: number;
errorCount: number;
}
export interface CreateProjectEvent {
provider: string;
providedKey: boolean;
}
export interface InitProjectEvent {
provider: string;
providedKey: boolean;
}
/**
* CLI analytics event map extending shared events with CLI-specific events.
*
* IMPORTANT: If an event is also tracked by WebUI, move it to SharedAnalyticsEventMap
* in @dexto/analytics to avoid duplication.
*/
export interface DextoAnalyticsEventMap extends SharedAnalyticsEventMap {
// CLI-specific events
dexto_cli_command: CliCommandEvent;
dexto_prompt: PromptEvent;
dexto_setup: SetupEvent;
dexto_install_agent: InstallAgentEvent;
dexto_install: InstallAggregateEvent;
dexto_uninstall_agent: UninstallAgentEvent;
dexto_uninstall: UninstallAggregateEvent;
dexto_create: CreateProjectEvent;
dexto_init: InitProjectEvent;
}
export type AnalyticsEventName = keyof DextoAnalyticsEventMap;
export type AnalyticsEventPayload<Name extends AnalyticsEventName> = DextoAnalyticsEventMap[Name];

View File

@@ -0,0 +1,214 @@
// packages/cli/src/analytics/index.ts
import { PostHog } from 'posthog-node';
import os from 'os';
import {
isAnalyticsDisabled,
DEFAULT_POSTHOG_HOST,
DEFAULT_POSTHOG_KEY,
loadState,
saveState,
} from '@dexto/analytics';
import type { AnalyticsState } from '@dexto/analytics';
import { getExecutionContext } from '@dexto/agent-management';
import { randomUUID } from 'crypto';
import {
AnalyticsEventName,
AnalyticsEventPayload,
BaseEventContext,
CliCommandEndEvent,
CliCommandStartEvent,
} from './events.js';
interface InitOptions {
appVersion: string;
}
let client: PostHog | null = null;
let enabled = false;
let state: AnalyticsState | null = null;
let sessionId: string | null = null;
let appVersion: string | null = null;
function baseProps(): BaseEventContext {
return {
app: 'dexto',
app_version: appVersion || 'unknown',
node_version: process.version,
os_platform: os.platform(),
os_release: os.release(),
os_arch: os.arch(),
execution_context: getExecutionContext(),
session_id: sessionId,
};
}
/**
* Initialize the analytics client for the CLI.
*
* - Respects DEXTO_ANALYTICS_DISABLED.
* - Creates/loads the anonymous distinctId and a per-process session_id.
* - Emits a dexto_session_created event for each process run.
*/
export async function initAnalytics(opts: InitOptions): Promise<void> {
if (enabled || client) return; // idempotent
if (isAnalyticsDisabled()) {
enabled = false;
return;
}
// Load or create state
state = await loadState();
sessionId = randomUUID();
appVersion = opts.appVersion;
const key = process.env.DEXTO_POSTHOG_KEY ?? DEFAULT_POSTHOG_KEY;
const host = process.env.DEXTO_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST;
if (typeof key !== 'string' || !/^phc_[A-Za-z0-9]+/.test(key) || !host) {
enabled = false;
return;
}
client = new PostHog(key, {
host,
flushAt: 1,
flushInterval: 0,
disableGeoip: false,
});
enabled = true;
process.on('exit', () => {
try {
client?.flush?.();
} catch {
// Ignore flush errors: analytics should never block process exit.
}
});
capture('dexto_session_created', {
source: 'cli',
sessionId: sessionId || 'unknown',
trigger: 'manual',
});
}
/**
* Capture a single analytics event with optional properties.
* Automatically enriches events with base context (app/os/node/session).
*/
export function capture<Name extends AnalyticsEventName>(
event: Name,
properties: AnalyticsEventPayload<Name> = {} as AnalyticsEventPayload<Name>
): void {
if (!enabled || !client || !state) return;
try {
client.capture({
distinctId: state.distinctId,
event,
properties: { ...baseProps(), ...properties },
});
} catch {
// swallow
}
}
/**
* Attempt a graceful shutdown of the analytics client, flushing queued events.
*/
export async function shutdownAnalytics(): Promise<void> {
if (client) {
try {
await client.shutdown();
} catch {
// ignore
}
}
}
// Commander hooks
type TimerMap = Map<string, number>;
const timers: TimerMap = new Map();
/**
* Mark the start of a command for timing and emit a lightweight start event.
* Adds local counters as a coarse diagnostic aid.
*/
export async function onCommandStart(
name: string,
extra: Partial<Omit<CliCommandStartEvent, 'name' | 'phase'>> = {}
): Promise<void> {
if (!enabled) return;
timers.set(name, Date.now());
if (state) {
await saveState(state);
}
const payload: CliCommandStartEvent = {
name,
phase: 'start',
...extra,
};
capture('dexto_cli_command', payload);
}
/**
* Mark the end of a command and emit a completion event with success/failure
* and measured duration. Accepts optional extra properties.
*/
export async function onCommandEnd(
name: string,
success: boolean,
extra: Partial<Omit<CliCommandEndEvent, 'name' | 'phase' | 'success' | 'durationMs'>> = {}
): Promise<void> {
if (!enabled) return;
const start = timers.get(name) ?? Date.now();
const durationMs = Date.now() - start;
timers.delete(name);
const payload: CliCommandEndEvent = {
name,
phase: 'end',
success,
durationMs,
...extra,
};
capture('dexto_cli_command', payload);
if (state) {
state.commandRunCounts = state.commandRunCounts || {};
state.commandRunCounts[name] = (state.commandRunCounts[name] || 0) + 1;
await saveState(state);
}
}
/**
* Whether analytics are currently enabled for this process.
*/
export function getEnabled(): boolean {
return enabled;
}
/**
* Build the analytics configuration for WebUI injection.
* Returns the config needed by the WebUI's PostHog client, or null if disabled.
*/
export async function getWebUIAnalyticsConfig(): Promise<{
distinctId: string;
posthogKey: string;
posthogHost: string;
appVersion: string;
} | null> {
if (isAnalyticsDisabled()) {
return null;
}
try {
const analyticsState = await loadState();
return {
distinctId: analyticsState.distinctId,
posthogKey: process.env.DEXTO_POSTHOG_KEY ?? DEFAULT_POSTHOG_KEY,
posthogHost: process.env.DEXTO_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST,
appVersion: appVersion || 'unknown',
};
} catch {
// If analytics state loading fails, return null to disable analytics
return null;
}
}

View File

@@ -0,0 +1,90 @@
// packages/cli/src/analytics/state.ts
import { promises as fs } from 'fs';
import * as path from 'path';
import os from 'os';
import { randomUUID, createHash } from 'crypto';
import { createRequire } from 'module';
const requireCJS = createRequire(import.meta.url);
// node-machine-id is CommonJS; import via createRequire to avoid ESM interop issues
const { machineIdSync } = requireCJS('node-machine-id') as {
machineIdSync: (original?: boolean) => string;
};
import { getDextoGlobalPath } from '@dexto/agent-management';
/**
* Shape of the persisted analytics state written to
* ~/.dexto/telemetry/state.json.
*
* - distinctId: Anonymous ID (UUID) for grouping events by machine.
* - createdAt: ISO timestamp when the state was first created.
* - commandRunCounts: Local counters per command for coarse diagnostics.
*/
export interface AnalyticsState {
distinctId: string;
createdAt: string; // ISO string
commandRunCounts?: Record<string, number>;
}
const STATE_DIR = getDextoGlobalPath('telemetry');
const STATE_FILE = path.join(STATE_DIR, 'state.json');
/**
* Load the persisted analytics state, creating a new file if missing.
* Returns a valid state object with defaults populated.
*/
export async function loadState(): Promise<AnalyticsState> {
try {
const content = await fs.readFile(STATE_FILE, 'utf8');
const parsed = JSON.parse(content) as Partial<AnalyticsState>;
// Validate minimal shape
if (!parsed.distinctId) throw new Error('invalid state');
return {
distinctId: parsed.distinctId,
createdAt: parsed.createdAt || new Date().toISOString(),
commandRunCounts: parsed.commandRunCounts ?? {},
};
} catch {
await fs.mkdir(STATE_DIR, { recursive: true });
const state: AnalyticsState = {
distinctId: computeDistinctId(),
createdAt: new Date().toISOString(),
commandRunCounts: {},
};
await saveState(state);
return state;
}
}
/**
* Persist the analytics state to ~/.dexto/telemetry/state.json.
*/
export async function saveState(state: AnalyticsState): Promise<void> {
await fs.mkdir(STATE_DIR, { recursive: true });
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
}
/**
* Compute a stable, privacysafe machine identifier so identity
* survives ~/.dexto deletion by default.
*
* Strategy:
* - Prefer node-machine-id (hashed), which abstracts platform differences.
* - Fallback to a salted/hashed hostname.
* - As a last resort, generate a random UUID.
*/
function computeDistinctId(): string {
try {
// machineIdSync(true) returns a hashed, stable identifier
const id = machineIdSync(true);
if (typeof id === 'string' && id.length > 0) return `DEXTO-${id}`;
} catch {
// fall through to hostname hash
}
// Fallback: hash hostname to avoid exposing raw value
const hostname = os.hostname() || 'unknown-host';
const digest = createHash('sha256').update(hostname).digest('hex');
if (digest) return `DEXTO-${digest.slice(0, 32)}`;
// Last resort
return `DEXTO-${randomUUID()}`;
}

View File

@@ -0,0 +1,137 @@
// packages/cli/src/analytics/wrapper.ts
import { onCommandStart, onCommandEnd, capture } from './index.js';
import { COMMAND_TIMEOUT_MS } from './constants.js';
import type {
CliCommandEndEvent,
CommandArgsMeta,
SanitizedOptionValue,
CliCommandTimeoutEvent,
} from './events.js';
function sanitizeOptions(obj: Record<string, unknown>): Record<string, SanitizedOptionValue> {
const redactedKeys = /key|token|secret|password|api[_-]?key|authorization|auth/i;
const truncate = (s: string, max = 256) => (s.length > max ? s.slice(0, max) + '…' : s);
const out: Record<string, SanitizedOptionValue> = {};
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string') {
out[k] = redactedKeys.test(k) ? '[REDACTED]' : truncate(v);
} else if (Array.isArray(v)) {
out[k] = { type: 'array', length: v.length };
} else if (typeof v === 'number' || typeof v === 'boolean' || v === null) {
out[k] = v as SanitizedOptionValue;
} else if (typeof v === 'object' && v) {
out[k] = { type: 'object' };
} else {
out[k] = String(v ?? 'unknown');
}
}
return out;
}
function buildArgsPayload(args: unknown[]): CommandArgsMeta {
const meta: CommandArgsMeta = {
argTypes: args.map((a) => (Array.isArray(a) ? 'array' : typeof a)),
};
if (args.length > 0 && Array.isArray(args[0])) {
const list = (args[0] as unknown[]).map((x) => String(x));
const trimmed = list.map((s) => (s.length > 512 ? s.slice(0, 512) + '…' : s)).slice(0, 10);
meta.positionalRaw = trimmed;
meta.positionalCount = list.length;
}
const last = args[args.length - 1];
if (last && typeof last === 'object' && !Array.isArray(last)) {
meta.optionKeys = Object.keys(last as Record<string, unknown>);
meta.options = sanitizeOptions(last as Record<string, unknown>);
}
return meta;
}
export function withAnalytics<A extends unknown[], R = unknown>(
commandName: string,
handler: (...args: A) => Promise<R> | R,
opts?: { timeoutMs?: number }
): (...args: A) => Promise<R> {
const timeoutMs = opts?.timeoutMs ?? COMMAND_TIMEOUT_MS;
return async (...args: A): Promise<R> => {
const argsMeta = buildArgsPayload(args as unknown[]);
await onCommandStart(commandName, { args: argsMeta });
const timeout =
timeoutMs > 0
? (() => {
const t = setTimeout(() => {
try {
const payload: CliCommandTimeoutEvent = {
name: commandName,
phase: 'timeout',
timeoutMs,
args: argsMeta,
};
capture('dexto_cli_command', payload);
} catch {
// Timeout instrumentation is best-effort.
}
}, timeoutMs);
// Prevent timeout from keeping process alive
t.unref();
return t;
})()
: null;
try {
const result = await handler(...args);
const success = (typeof process.exitCode === 'number' ? process.exitCode : 0) === 0;
await onCommandEnd(commandName, success, { args: argsMeta });
return result as R;
} catch (err) {
if (err instanceof ExitSignal) {
const exitCode = err.code ?? 0;
process.exitCode = exitCode;
try {
const endMeta: Partial<
Omit<CliCommandEndEvent, 'name' | 'phase' | 'success' | 'durationMs'>
> & { args: CommandArgsMeta } = { args: argsMeta };
if (typeof err.reason === 'string') {
endMeta.reason = err.reason;
}
if (err.commandName) {
endMeta.command = err.commandName;
}
await onCommandEnd(commandName, exitCode === 0, endMeta);
} catch {
// Ignore analytics errors when propagating ExitSignal.
}
// Actually exit the process after analytics
process.exit(exitCode);
}
try {
await onCommandEnd(commandName, false, {
error: err instanceof Error ? err.message : String(err),
args: argsMeta,
});
} catch {
// Ignore analytics errors when recording failures.
}
throw err;
} finally {
if (timeout) clearTimeout(timeout);
}
};
}
export class ExitSignal extends Error {
code: number;
reason?: string | undefined;
commandName?: string | undefined;
constructor(code: number = 0, reason?: string, commandName?: string) {
super('ExitSignal');
this.name = 'ExitSignal';
this.code = code;
this.reason = reason;
this.commandName = commandName;
}
}
export function safeExit(commandName: string, code: number = 0, reason?: string): never {
throw new ExitSignal(code, reason, commandName);
}

View File

@@ -0,0 +1,127 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import {
MCPManager,
logger,
type ValidatedServerConfigs,
jsonSchemaToZodShape,
createLogger,
DextoLogComponent,
} from '@dexto/core';
import { z } from 'zod';
/**
* Initializes MCP server for tool aggregation mode.
* Instead of exposing an AI agent, this directly exposes all tools from connected MCP servers.
*/
export async function initializeMcpToolAggregationServer(
serverConfigs: ValidatedServerConfigs,
mcpTransport: Transport,
serverName: string,
serverVersion: string,
_strict: boolean
): Promise<McpServer> {
// Create MCP manager with no confirmation provider (tools are auto-approved)
const mcpLogger = createLogger({
config: {
level: 'info',
transports: [{ type: 'console', colorize: true }],
},
agentId: 'mcp-tool-aggregation',
component: DextoLogComponent.MCP,
});
const mcpManager = new MCPManager(mcpLogger);
// Initialize all MCP server connections from config
logger.info('Connecting to configured MCP servers for tool aggregation...');
await mcpManager.initializeFromConfig(serverConfigs);
// Create the aggregation MCP server
const mcpServer = new McpServer(
{ name: serverName, version: serverVersion },
{
capabilities: {
tools: {},
resources: {},
prompts: {},
},
}
);
const toolDefinitions = await mcpManager.getAllTools();
let toolCount = 0;
for (const [toolName, toolDef] of Object.entries(toolDefinitions)) {
toolCount++;
const jsonSchema = toolDef.parameters ?? { type: 'object', properties: {} };
const paramsShape = jsonSchemaToZodShape(jsonSchema);
const _paramsSchema = z.object(paramsShape);
type ToolArgs = z.output<typeof _paramsSchema>;
logger.debug(`Registering tool '${toolName}' with schema: ${JSON.stringify(jsonSchema)}`);
mcpServer.tool(
toolName,
toolDef.description || `Tool: ${toolName}`,
paramsShape,
async (args: ToolArgs) => {
logger.info(`Tool aggregation: executing ${toolName}`);
try {
const result = await mcpManager.executeTool(toolName, args);
logger.info(`Tool aggregation: ${toolName} completed successfully`);
return result;
} catch (error) {
logger.error(`Tool aggregation: ${toolName} failed: ${error}`);
throw error;
}
}
);
}
logger.info(`Registered ${toolCount} tools from connected MCP servers`);
// Register resources if available
try {
const allResources = await mcpManager.listAllResources();
logger.info(`Registering ${allResources.length} resources from connected MCP servers`);
// Collision handling verified:
// - Tools/Prompts: Names come from mcpManager which handles collisions at source
// - Resources: Index prefix ensures uniqueness even if multiple clients have same key
allResources.forEach((resource, index) => {
const safeId = resource.key.replace(/[^a-zA-Z0-9]/g, '_');
mcpServer.resource(`resource_${index}_${safeId}`, resource.key, async () => {
logger.info(`Resource aggregation: reading ${resource.key}`);
return await mcpManager.readResource(resource.key);
});
});
} catch (error) {
logger.debug(`Skipping resource aggregation: ${error}`);
}
// Register prompts if available
try {
const allPrompts = await mcpManager.listAllPrompts();
logger.info(`Registering ${allPrompts.length} prompts from connected MCP servers`);
for (const promptName of allPrompts) {
mcpServer.prompt(promptName, `Prompt: ${promptName}`, async (extra) => {
logger.info(`Prompt aggregation: resolving ${promptName}`);
const promptArgs: Record<string, unknown> | undefined =
extra && 'arguments' in extra
? (extra.arguments as Record<string, unknown>)
: undefined;
return await mcpManager.getPrompt(promptName, promptArgs);
});
}
} catch (error) {
logger.debug(`Skipping prompt aggregation: ${error}`);
}
// Connect server to transport
logger.info(`Connecting MCP tool aggregation server...`);
await mcpServer.connect(mcpTransport);
logger.info(`✅ MCP tool aggregation server connected with ${toolCount} tools exposed`);
return mcpServer;
}

View File

View File

@@ -0,0 +1,590 @@
import os from 'node:os';
import type { Context } from 'hono';
import type { AgentCard } from '@dexto/core';
import { DextoAgent, createAgentCard, logger, AgentError } from '@dexto/core';
import {
loadAgentConfig,
enrichAgentConfig,
deriveDisplayName,
getAgentRegistry,
AgentFactory,
globalPreferencesExist,
loadGlobalPreferences,
} from '@dexto/agent-management';
import { applyUserPreferences } from '../config/cli-overrides.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import {
createDextoApp,
createNodeServer,
createMcpTransport as createServerMcpTransport,
createMcpHttpHandlers,
initializeMcpServer as initializeServerMcpServer,
createManualApprovalHandler,
WebhookEventSubscriber,
A2ASseEventSubscriber,
ApprovalCoordinator,
type McpTransportType,
type WebUIRuntimeConfig,
} from '@dexto/server';
import { registerGracefulShutdown } from '../utils/graceful-shutdown.js';
const DEFAULT_AGENT_VERSION = '1.0.0';
/**
* Load image dynamically based on config and environment
* Priority: Config image field > Environment variable > Default
* Images are optional, but default to image-local for convenience
*
* @returns Image metadata including bundled plugins, or null if image has no metadata export
*/
async function loadImageForConfig(config: {
image?: string | undefined;
}): Promise<{ bundledPlugins?: string[] } | null> {
const imageName = config.image || process.env.DEXTO_IMAGE || '@dexto/image-local';
try {
const imageModule = await import(imageName);
logger.debug(`Loaded image: ${imageName}`);
// Extract metadata if available (built images export imageMetadata)
if (imageModule.imageMetadata) {
return imageModule.imageMetadata;
}
return null;
} catch (err) {
const errorMsg = `Failed to load image '${imageName}': ${err instanceof Error ? err.message : String(err)}`;
logger.error(errorMsg);
throw new Error(errorMsg);
}
}
/**
* List all agents (installed and available)
* Replacement for old Dexto.listAgents()
*/
async function listAgents(): Promise<{
installed: Array<{
id: string;
name: string;
description: string;
author?: string;
tags?: string[];
type: 'builtin' | 'custom';
}>;
available: Array<{
id: string;
name: string;
description: string;
author?: string;
tags?: string[];
type: 'builtin' | 'custom';
}>;
}> {
return AgentFactory.listAgents({
descriptionFallback: 'No description',
customAgentDescriptionFallback: 'Custom agent',
});
}
/**
* Create an agent from an agent ID
* Replacement for old Dexto.createAgent()
* Uses registry.resolveAgent() which auto-installs if needed
*
* Applies user preferences (preferences.yml) to ALL agents, not just the default.
* See feature-plans/auto-update.md section 8.11 - Three-Layer LLM Resolution.
*/
async function createAgentFromId(agentId: string): Promise<DextoAgent> {
try {
// Use registry to resolve agent path (auto-installs if not present)
const registry = getAgentRegistry();
const agentPath = await registry.resolveAgent(agentId, true);
// Load agent config
let config = await loadAgentConfig(agentPath);
// Apply user's LLM preferences to ALL agents
// Three-Layer Resolution: local.llm ?? preferences.llm ?? bundled.llm
if (globalPreferencesExist()) {
try {
const preferences = await loadGlobalPreferences();
if (preferences?.llm?.provider && preferences?.llm?.model) {
config = applyUserPreferences(config, preferences);
logger.debug(`Applied user preferences to ${agentId}`, {
provider: preferences.llm.provider,
model: preferences.llm.model,
});
}
} catch {
logger.debug('Could not load preferences, using bundled config');
}
}
// Load image to get bundled plugins
const imageMetadata = await loadImageForConfig(config);
// Enrich config with per-agent paths and bundled plugins
const enrichedConfig = enrichAgentConfig(config, agentPath, {
logLevel: 'info', // Server uses info-level logging for visibility
bundledPlugins: imageMetadata?.bundledPlugins || [],
});
// Create agent instance
logger.info(`Creating agent: ${agentId} from ${agentPath}`);
return new DextoAgent(enrichedConfig, agentPath);
} catch (error) {
throw new Error(
`Failed to create agent '${agentId}': ${error instanceof Error ? error.message : String(error)}`
);
}
}
function resolvePort(listenPort?: number): number {
if (typeof listenPort === 'number') {
return listenPort;
}
const envPort = Number(process.env.PORT);
return Number.isFinite(envPort) && envPort > 0 ? envPort : 3000;
}
function resolveBaseUrl(port: number): string {
return process.env.DEXTO_BASE_URL ?? `http://localhost:${port}`;
}
export type HonoInitializationResult = {
app: ReturnType<typeof createDextoApp>;
server: ReturnType<typeof createNodeServer>['server'];
webhookSubscriber?: NonNullable<ReturnType<typeof createNodeServer>['webhookSubscriber']>;
agentCard: AgentCard;
mcpTransport?: Transport;
switchAgentById: (agentId: string) => Promise<{ id: string; name: string }>;
switchAgentByPath: (filePath: string) => Promise<{ id: string; name: string }>;
resolveAgentInfo: (agentId: string) => Promise<{ id: string; name: string }>;
ensureAgentAvailable: () => void;
getActiveAgentId: () => string | undefined;
};
//TODO (migration): consider moving this to the server package
export async function initializeHonoApi(
agent: DextoAgent,
agentCardOverride?: Partial<AgentCard>,
listenPort?: number,
agentId?: string,
webRoot?: string,
webUIConfig?: WebUIRuntimeConfig
): Promise<HonoInitializationResult> {
// Declare before registering shutdown hook to avoid TDZ on signals
let activeAgent: DextoAgent = agent;
let activeAgentId: string | undefined = agentId || 'coding-agent';
let isSwitchingAgent = false;
registerGracefulShutdown(() => activeAgent);
const resolvedPort = resolvePort(listenPort);
const baseApiUrl = resolveBaseUrl(resolvedPort);
// Apply agentCard overrides (if any)
const overrides = agentCardOverride ?? {};
let agentCardData = createAgentCard(
{
defaultName: overrides.name ?? activeAgentId,
defaultVersion: overrides.version ?? DEFAULT_AGENT_VERSION,
defaultBaseUrl: baseApiUrl,
},
overrides
);
// Create event subscribers and approval coordinator (shared across agent switches)
const webhookSubscriber = new WebhookEventSubscriber();
const sseSubscriber = new A2ASseEventSubscriber();
const approvalCoordinator = new ApprovalCoordinator();
/**
* Wire services (SSE subscribers) to an agent.
* Called for agent switching to re-subscribe to the new agent's event bus.
* Note: Approval handler and coordinator are set before agent.start() for each agent.
*/
async function wireServicesToAgent(agent: DextoAgent): Promise<void> {
logger.debug('Wiring services to agent...');
// Subscribe to event bus (methods handle aborting previous subscriptions)
webhookSubscriber.subscribe(agent.agentEventBus);
sseSubscriber.subscribe(agent.agentEventBus);
// Note: ApprovalCoordinator doesn't subscribe to agent event bus
// It's a separate coordination channel between handler and server
}
/**
* Helper to resolve agent ID to { id, name } by looking up in registry
*/
async function resolveAgentInfo(agentId: string): Promise<{ id: string; name: string }> {
const agents = await listAgents();
const agent =
agents.installed.find((a) => a.id === agentId) ??
agents.available.find((a) => a.id === agentId);
return {
id: agentId,
name: agent?.name ?? deriveDisplayName(agentId),
};
}
function ensureAgentAvailable(): void {
// Gate requests during agent switching
if (isSwitchingAgent) {
throw AgentError.switchInProgress();
}
// Fast path: most common case is agent is started and running
if (activeAgent.isStarted() && !activeAgent.isStopped()) {
return;
}
// Provide specific error messages for better debugging
if (activeAgent.isStopped()) {
throw AgentError.stopped();
}
if (!activeAgent.isStarted()) {
throw AgentError.notStarted();
}
}
/**
* Common agent switching logic shared by switchAgentById and switchAgentByPath.
*/
async function performAgentSwitch(
newAgent: DextoAgent,
agentId: string,
bridge: ReturnType<typeof createNodeServer>
) {
logger.info('Preparing new agent for switch...');
// Register webhook subscriber for LLM streaming events
if (bridge.webhookSubscriber) {
newAgent.registerSubscriber(bridge.webhookSubscriber);
}
// Switch activeAgent reference first
const previousAgent = activeAgent;
activeAgent = newAgent;
activeAgentId = agentId;
// Set approval handler if manual mode OR elicitation enabled (before start() for validation)
const needsHandler =
newAgent.config.toolConfirmation?.mode === 'manual' ||
newAgent.config.elicitation.enabled;
if (needsHandler) {
logger.debug('Setting up manual approval handler for new agent...');
const handler = createManualApprovalHandler(approvalCoordinator);
newAgent.setApprovalHandler(handler);
}
// Wire SSE subscribers BEFORE starting
logger.info('Wiring services to new agent...');
await wireServicesToAgent(newAgent);
logger.info(`Starting new agent: ${agentId}`);
await newAgent.start();
// Update agent card for A2A and MCP routes
agentCardData = createAgentCard(
{
defaultName: agentId,
defaultVersion: overrides.version ?? DEFAULT_AGENT_VERSION,
defaultBaseUrl: baseApiUrl,
},
overrides
);
logger.info(`Successfully switched to agent: ${agentId}`);
// Now safely stop the previous agent
try {
if (previousAgent && previousAgent !== newAgent) {
logger.info('Stopping previous agent...');
await previousAgent.stop();
}
} catch (err) {
logger.warn(`Stopping previous agent failed: ${err}`);
// Don't throw here as the switch was successful
}
return await resolveAgentInfo(agentId);
}
async function switchAgentById(agentId: string, bridge: ReturnType<typeof createNodeServer>) {
if (isSwitchingAgent) {
throw AgentError.switchInProgress();
}
isSwitchingAgent = true;
let newAgent: DextoAgent | undefined;
try {
// 1. SHUTDOWN OLD TELEMETRY FIRST (before creating new agent)
logger.info('Shutting down telemetry for agent switch...');
const { Telemetry } = await import('@dexto/core');
await Telemetry.shutdownGlobal();
// 2. Create new agent from registry (will initialize fresh telemetry in createAgentServices)
newAgent = await createAgentFromId(agentId);
// 3. Use common switch logic (register subscribers, start agent, stop previous)
return await performAgentSwitch(newAgent, agentId, bridge);
} catch (error) {
logger.error(
`Failed to switch to agent '${agentId}': ${
error instanceof Error ? error.message : String(error)
}`,
{ error }
);
// Clean up the failed new agent if it was created
if (newAgent) {
try {
await newAgent.stop();
} catch (cleanupErr) {
logger.warn(`Failed to cleanup new agent: ${cleanupErr}`);
}
}
throw error;
} finally {
isSwitchingAgent = false;
}
}
async function switchAgentByPath(
filePath: string,
bridge: ReturnType<typeof createNodeServer>
) {
if (isSwitchingAgent) {
throw AgentError.switchInProgress();
}
isSwitchingAgent = true;
let newAgent: DextoAgent | undefined;
try {
// 1. SHUTDOWN OLD TELEMETRY FIRST (before creating new agent)
logger.info('Shutting down telemetry for agent switch...');
const { Telemetry } = await import('@dexto/core');
await Telemetry.shutdownGlobal();
// 2. Load agent configuration from file path
let config = await loadAgentConfig(filePath);
// 2.5. Apply user's LLM preferences to ALL agents
// Three-Layer Resolution: local.llm ?? preferences.llm ?? bundled.llm
if (globalPreferencesExist()) {
try {
const preferences = await loadGlobalPreferences();
if (preferences?.llm?.provider && preferences?.llm?.model) {
config = applyUserPreferences(config, preferences);
logger.debug(
`Applied user preferences to agent from ${filePath} (provider=${preferences.llm.provider}, model=${preferences.llm.model})`
);
}
} catch {
logger.debug('Could not load preferences, using bundled config');
}
}
// 3. Load image first to get bundled plugins
const imageMetadata = await loadImageForConfig(config);
// 3.5. Enrich config with per-agent paths and bundled plugins from image
const enrichedConfig = enrichAgentConfig(config, filePath, {
logLevel: 'info', // Server uses info-level logging for visibility
bundledPlugins: imageMetadata?.bundledPlugins || [],
});
// 4. Create new agent instance directly (will initialize fresh telemetry in createAgentServices)
newAgent = new DextoAgent(enrichedConfig, filePath);
// 5. Use enriched agentId (derived from config or filename during enrichment)
// enrichAgentConfig always sets agentId, so it's safe to assert non-null
const agentId = enrichedConfig.agentId!;
// 6. Use common switch logic (register subscribers, start agent, stop previous)
return await performAgentSwitch(newAgent, agentId, bridge);
} catch (error) {
logger.error(
`Failed to switch to agent from path '${filePath}': ${
error instanceof Error ? error.message : String(error)
}`,
{ error }
);
// Clean up the failed new agent if it was created
if (newAgent) {
try {
await newAgent.stop();
} catch (cleanupErr) {
logger.warn(`Failed to cleanup new agent: ${cleanupErr}`);
}
}
throw error;
} finally {
isSwitchingAgent = false;
}
}
// Getter functions for routes (always use current agent)
// getAgent automatically ensures agent is available before returning it
// Accepts Context parameter for compatibility with GetAgentFn type
const getAgent = (_ctx: Context): DextoAgent => {
// CRITICAL: Check agent availability before every access to prevent race conditions
// during agent switching, stopping, or startup failures
ensureAgentAvailable();
return activeAgent;
};
const getAgentCard = () => agentCardData;
// Declare bridge variable that will be set later
let bridgeRef: ReturnType<typeof createNodeServer> | null = null;
// Create app with agentsContext using closure
const app = createDextoApp({
apiPrefix: '/api',
getAgent,
getAgentCard,
approvalCoordinator,
webhookSubscriber,
sseSubscriber,
...(webRoot ? { webRoot } : {}),
...(webUIConfig ? { webUIConfig } : {}),
agentsContext: {
switchAgentById: (id: string) => {
if (!bridgeRef) throw new Error('Bridge not initialized');
return switchAgentById(id, bridgeRef);
},
switchAgentByPath: (filePath: string) => {
if (!bridgeRef) throw new Error('Bridge not initialized');
return switchAgentByPath(filePath, bridgeRef);
},
resolveAgentInfo,
ensureAgentAvailable,
getActiveAgentId: () => activeAgentId,
},
});
let mcpTransport: Transport | undefined;
const transportType = (process.env.DEXTO_MCP_TRANSPORT_TYPE as McpTransportType) || 'http';
try {
mcpTransport = await createServerMcpTransport(transportType);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to create MCP transport: ${errorMessage}`);
mcpTransport = undefined;
}
// Create bridge with app
bridgeRef = createNodeServer(app, {
getAgent: () => activeAgent,
mcpHandlers: mcpTransport ? createMcpHttpHandlers(mcpTransport) : null,
});
// Register webhook subscriber for LLM streaming events
logger.info('Registering webhook subscriber with agent...');
if (bridgeRef.webhookSubscriber) {
activeAgent.registerSubscriber(bridgeRef.webhookSubscriber);
}
// Update agent card
agentCardData = createAgentCard(
{
defaultName: overrides.name ?? activeAgentId,
defaultVersion: overrides.version ?? DEFAULT_AGENT_VERSION,
defaultBaseUrl: baseApiUrl,
},
overrides
);
// Set approval handler for initial agent if manual mode OR elicitation enabled (before start() for validation)
const needsHandler =
activeAgent.config.toolConfirmation?.mode === 'manual' ||
activeAgent.config.elicitation.enabled;
if (needsHandler) {
logger.debug('Setting up manual approval handler for initial agent...');
const handler = createManualApprovalHandler(approvalCoordinator);
activeAgent.setApprovalHandler(handler);
}
// Wire SSE subscribers to initial agent
logger.info('Wiring SSE subscribers to initial agent...');
await wireServicesToAgent(activeAgent);
// Start the initial agent now that approval handler is set and subscribers are wired
logger.info('Starting initial agent...');
await activeAgent.start();
// Initialize MCP server after agent has started
if (mcpTransport) {
try {
await initializeServerMcpServer(activeAgent, getAgentCard(), mcpTransport);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to initialize MCP server: ${errorMessage}`);
mcpTransport = undefined;
}
}
return {
app,
server: bridgeRef.server,
...(bridgeRef.webhookSubscriber ? { webhookSubscriber: bridgeRef.webhookSubscriber } : {}),
agentCard: agentCardData,
...(mcpTransport ? { mcpTransport } : {}),
// Expose switching functions for agent routes
switchAgentById: (id: string) => switchAgentById(id, bridgeRef!),
switchAgentByPath: (filePath: string) => switchAgentByPath(filePath, bridgeRef!),
resolveAgentInfo,
ensureAgentAvailable,
getActiveAgentId: () => activeAgentId,
};
}
export async function startHonoApiServer(
agent: DextoAgent,
port = 3000,
agentCardOverride?: Partial<AgentCard>,
agentId?: string,
webRoot?: string,
webUIConfig?: WebUIRuntimeConfig
): Promise<{
server: ReturnType<typeof createNodeServer>['server'];
webhookSubscriber?: NonNullable<ReturnType<typeof createNodeServer>['webhookSubscriber']>;
}> {
const { server, webhookSubscriber } = await initializeHonoApi(
agent,
agentCardOverride,
port,
agentId,
webRoot,
webUIConfig
);
server.listen(port, '0.0.0.0', () => {
const networkInterfaces = os.networkInterfaces();
let localIp = 'localhost';
Object.values(networkInterfaces).forEach((ifaceList) => {
ifaceList?.forEach((iface) => {
if (iface.family === 'IPv4' && !iface.internal) {
localIp = iface.address;
}
});
});
logger.info(
`Hono server started successfully. Accessible at: http://localhost:${port} and http://${localIp}:${port} on your local network.`,
null,
'green'
);
});
return {
server,
...(webhookSubscriber ? { webhookSubscriber } : {}),
};
}

View File

@@ -0,0 +1,245 @@
# Webhook API Documentation
The Dexto webhook system provides HTTP-based event delivery for agent events, offering an alternative to SSE streams for cloud integrations.
## Overview
Webhooks allow you to receive real-time notifications about agent events by registering HTTP endpoints that will receive POST requests when events occur. This is similar to how Stripe webhooks work - when something happens in your Dexto agent, we'll send a POST request to your configured webhook URL.
## Event Structure
All webhook events follow a consistent structure inspired by Stripe's webhook events:
```typescript
interface DextoWebhookEvent<T extends AgentEventName = AgentEventName> {
id: string; // Unique event ID (e.g., "evt_1234567890_abc123def")
type: T; // Event type with TypeScript autocomplete
data: AgentEventMap[T]; // Event-specific payload
created: string; // ISO-8601 timestamp of when the event occurred
apiVersion: string; // API version (currently "2025-07-03")
}
```
## TypeScript Autocomplete Support
The webhook system provides full TypeScript autocomplete support for event types, similar to Stripe's implementation:
```typescript
// Your IDE will autocomplete available event types
if (event.type === "llm:response") {
// TypeScript knows event.data has response-specific fields
console.log(event.data.content);
console.log(event.data.tokenUsage?.totalTokens);
}
```
## Available Event Types
The webhook system supports all integration events (Tier 2 events):
**LLM Events:**
- `llm:thinking` - AI model is processing
- `llm:chunk` - Streaming response chunk received
- `llm:tool-call` - Tool execution requested
- `llm:tool-result` - Tool execution completed
- `llm:response` - Final AI response received
- `llm:error` - Error during AI processing
- `llm:unsupported-input` - Input type not supported by selected model
- `llm:switched` - LLM provider or model switched
**Session Events:**
- `session:title-updated` - Session title was automatically updated
- `session:created` - New conversation session created
- `session:reset` - Conversation history cleared
**MCP Events:**
- `mcp:server-connected` - MCP server connection established
- `mcp:server-restarted` - MCP server was restarted
- `mcp:tools-list-changed` - MCP server tools changed
- `mcp:prompts-list-changed` - MCP server prompts changed
**Tool Events:**
- `tools:available-updated` - Available tools changed (MCP + built-in)
**State Events:**
- `state:changed` - Agent state updated
## Webhook Management API
### Register a Webhook
```bash
POST /api/webhooks
Content-Type: application/json
{
"url": "https://your-app.com/webhooks/dexto",
"secret": "whsec_your_secret_key", // Optional for signature verification
"description": "Production webhook" // Optional description
}
```
Response:
```json
{
"webhook": {
"id": "wh_1703123456_abc123def",
"url": "https://your-app.com/webhooks/dexto",
"description": "Production webhook",
"createdAt": "2025-01-01T12:00:00.000Z"
}
}
```
### List Webhooks
```bash
GET /api/webhooks
```
### Get Specific Webhook
```bash
GET /api/webhooks/{webhook_id}
```
### Remove Webhook
```bash
DELETE /api/webhooks/{webhook_id}
```
### Test Webhook
```bash
POST /api/webhooks/{webhook_id}/test
```
This sends a test `tools:available-updated` event to verify your endpoint is working.
## Security & Signature Verification
When you provide a `secret` during webhook registration, Dexto will include an HMAC signature in the `X-Dexto-Signature-256` header for verification:
```
X-Dexto-Signature-256: sha256=a1b2c3d4e5f6...
```
### Verifying Signatures (Node.js Example)
```javascript
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
const expected = `sha256=${expectedSignature}`;
return crypto.timingSafeEqual(
Buffer.from(signature, 'utf8'),
Buffer.from(expected, 'utf8')
);
}
// In your webhook handler
app.post('/webhooks/dexto', async (c) => {
const signature = c.req.header('x-dexto-signature-256');
const payload = await c.req.text();
if (!verifyWebhookSignature(payload, signature, 'your_secret')) {
return c.text('Unauthorized', 401);
}
// Parse only *after* signature verification
const event = JSON.parse(payload);
console.log(`Received ${event.type} event:`, event.data);
return c.text('OK', 200);
});
```
## HTTP Headers
Each webhook request includes these headers:
- `Content-Type: application/json`
- `User-Agent: DextoAgent/1.0`
- `X-Dexto-Event-Type: {event_type}`
- `X-Dexto-Event-Id: {event_id}`
- `X-Dexto-Delivery-Attempt: {attempt_number}`
- `X-Dexto-Signature-256: sha256={signature}` (if secret provided)
## Delivery & Retry Logic
- **Delivery**: Webhooks are delivered asynchronously and don't block agent operations
- **Timeout**: 10 second timeout per request
- **Retries**: Up to 3 attempts with exponential backoff (1s, 2s, 4s)
- **Success**: HTTP 2xx status codes are considered successful
- **Failure**: Non-2xx responses or network errors trigger retries
## Best Practices
1. **Respond Quickly**: Return a 2xx status code as fast as possible. Process events asynchronously if needed.
2. **Handle Duplicates**: Due to retries, you might receive the same event multiple times. Use the `event.id` for deduplication.
3. **Verify Signatures**: Always verify webhook signatures in production to ensure requests are from Dexto.
4. **Use HTTPS**: Always use HTTPS URLs for webhook endpoints to ensure secure delivery.
5. **Handle All Event Types**: Your webhook should handle unknown event types gracefully as new events may be added.
## Example Webhook Handler
```typescript
import { Hono } from 'hono';
import type { DextoWebhookEvent } from '@dexto/server';
const app = new Hono();
app.post('/webhooks/dexto', async (c) => {
// Parse JSON from request body
const event: DextoWebhookEvent = await c.req.json();
try {
switch (event.type) {
case 'llm:response':
console.log('AI Response:', event.data.content);
break;
case 'llm:tool-call':
console.log('Tool Called:', event.data.toolName);
break;
case 'session:reset':
console.log('Conversation reset for session:', event.data.sessionId);
break;
default:
console.log('Unknown event type:', event.type);
}
return c.text('OK', 200);
} catch (error) {
console.error('Webhook error:', error);
return c.text('Internal Server Error', 500);
}
});
```
## Differences from SSE
| Feature | SSE | Webhooks |
|---------|-----|----------|
| Connection | Persistent connection required | Stateless HTTP requests |
| Delivery | Real-time | Near real-time with retries |
| Scalability | Limited by connection count | Scales with HTTP infrastructure |
| Reliability | Connection can drop | Built-in retry mechanism |
| Development | Requires EventSource client | Standard HTTP endpoint |
| Cloud-friendly | Requires persistent connections | Works with serverless functions |
## Server Mode Requirement
Webhooks are only available when Dexto is running in server mode (`dexto --mode server` command). They are not available in CLI or other modes since webhooks require the HTTP API server to be running.

View File

@@ -0,0 +1,188 @@
/**
* CLI-specific Approval Handler
*
* Creates a manual approval handler that works directly with AgentEventBus
* for the CLI/TUI mode. Unlike the server's ManualApprovalHandler which uses
* ApprovalCoordinator for HTTP-based flows, this handler emits events directly
* to the event bus that the TUI listens to.
*
* Flow:
* 1. Handler emits 'approval:request' → EventBus → TUI shows prompt
* 2. User responds in TUI → EventBus emits 'approval:response' → Handler resolves
* 3. For auto-approvals (parallel tools), handler emits 'approval:response' → TUI dismisses
*/
import type {
ApprovalHandler,
ApprovalRequest,
ApprovalResponse,
AgentEventBus,
} from '@dexto/core';
import { ApprovalStatus, DenialReason } from '@dexto/core';
/**
* Creates a manual approval handler for CLI mode that uses AgentEventBus directly.
*
* @param eventBus The agent event bus for request/response communication
* @returns ApprovalHandler with cancellation and auto-approve support
*
* @example
* ```typescript
* const handler = createCLIApprovalHandler(agent.agentEventBus);
* agent.setApprovalHandler(handler);
* ```
*/
export function createCLIApprovalHandler(eventBus: AgentEventBus): ApprovalHandler {
// Track pending approvals for cancellation support
const pendingApprovals = new Map<
string,
{
cleanup: () => void;
resolve: (response: ApprovalResponse) => void;
request: ApprovalRequest;
}
>();
const handleApproval = (request: ApprovalRequest): Promise<ApprovalResponse> => {
return new Promise<ApprovalResponse>((resolve) => {
// Use per-request timeout (optional - undefined means no timeout)
const effectiveTimeout = request.timeout;
// Set timeout timer ONLY if timeout is specified
let timer: NodeJS.Timeout | undefined;
if (effectiveTimeout !== undefined) {
timer = setTimeout(() => {
cleanup();
pendingApprovals.delete(request.approvalId);
// Create timeout response
const timeoutResponse: ApprovalResponse = {
approvalId: request.approvalId,
status: ApprovalStatus.CANCELLED,
sessionId: request.sessionId,
reason: DenialReason.TIMEOUT,
message: `Approval request timed out after ${effectiveTimeout}ms`,
timeoutMs: effectiveTimeout,
};
// Emit timeout response so TUI can dismiss the prompt
eventBus.emit('approval:response', timeoutResponse);
resolve(timeoutResponse);
}, effectiveTimeout);
}
// Cleanup function to remove listener and clear timeout
const controller = new AbortController();
const cleanup = () => {
if (timer !== undefined) {
clearTimeout(timer);
}
controller.abort();
};
// Listen for approval:response events
const listener = (res: ApprovalResponse) => {
// Only handle responses for this specific approval
if (res.approvalId === request.approvalId) {
cleanup();
pendingApprovals.delete(request.approvalId);
resolve(res);
}
};
// Register listener with abort signal for cleanup
eventBus.on('approval:response', listener, { signal: controller.signal });
// Store for cancellation support
pendingApprovals.set(request.approvalId, {
cleanup,
resolve,
request,
});
// Emit the approval:request event for TUI to receive
eventBus.emit('approval:request', request);
});
};
const handler: ApprovalHandler = Object.assign(handleApproval, {
cancel: (approvalId: string): void => {
const pending = pendingApprovals.get(approvalId);
if (pending) {
pending.cleanup();
pendingApprovals.delete(approvalId);
// Create cancellation response
const cancelResponse: ApprovalResponse = {
approvalId,
status: ApprovalStatus.CANCELLED,
sessionId: pending.request.sessionId,
reason: DenialReason.SYSTEM_CANCELLED,
message: 'Approval request was cancelled',
};
// Emit cancellation event so TUI can dismiss the prompt
eventBus.emit('approval:response', cancelResponse);
// Resolve with CANCELLED response
pending.resolve(cancelResponse);
}
},
cancelAll: (): void => {
for (const [approvalId] of pendingApprovals) {
handler.cancel?.(approvalId);
}
},
getPending: (): string[] => {
return Array.from(pendingApprovals.keys());
},
getPendingRequests: (): ApprovalRequest[] => {
return Array.from(pendingApprovals.values()).map((p) => p.request);
},
/**
* Auto-approve pending requests that match a predicate.
* Used when a pattern is remembered to auto-approve other parallel requests
* that would now match the same pattern.
*/
autoApprovePending: (
predicate: (request: ApprovalRequest) => boolean,
responseData?: Record<string, unknown>
): number => {
let count = 0;
// Find all pending approvals that match the predicate
for (const [approvalId, pending] of pendingApprovals) {
if (predicate(pending.request)) {
// Clean up the pending state
pending.cleanup();
pendingApprovals.delete(approvalId);
// Create auto-approval response
const autoApproveResponse: ApprovalResponse = {
approvalId,
status: ApprovalStatus.APPROVED,
sessionId: pending.request.sessionId,
message: 'Auto-approved due to matching remembered pattern',
data: responseData,
};
// Emit response so TUI can dismiss the prompt
eventBus.emit('approval:response', autoApproveResponse);
// Resolve the pending promise
pending.resolve(autoApproveResponse);
count++;
}
}
return count;
},
});
return handler;
}

View File

@@ -0,0 +1,7 @@
/**
* CLI Approval Module
*
* Provides CLI-specific approval handling that works directly with AgentEventBus.
*/
export { createCLIApprovalHandler } from './cli-approval-handler.js';

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,183 @@
// packages/cli/src/cli/auth/api-client.ts
// Dexto API client for key management and usage
// TODO: Migrate to typed client for type safety and better DX
// Options:
// 1. Migrate dexto-web APIs to Hono and use @hono/client (like packages/server)
// 2. Use openapi-fetch with generated types from OpenAPI spec
// 3. Use tRPC if we want full-stack type safety
// Currently using plain fetch() with manual type definitions.
import { logger } from '@dexto/core';
import { DEXTO_API_URL } from './constants.js';
interface ProvisionResponse {
success: boolean;
dextoApiKey?: string;
keyId?: string;
isNewKey?: boolean;
error?: string;
}
export interface UsageSummaryResponse {
credits_usd: number;
mtd_usage: {
total_cost_usd: number;
total_requests: number;
by_model: Record<
string,
{
requests: number;
cost_usd: number;
tokens: number;
}
>;
};
recent: Array<{
timestamp: string;
model: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;
}>;
}
/**
* Dexto API client for key management
*/
export class DextoApiClient {
private readonly baseUrl: string;
private readonly timeoutMs = 10_000; // 10 second timeout for API calls
constructor(baseUrl: string = DEXTO_API_URL) {
this.baseUrl = baseUrl;
}
/**
* Validate if a Dexto API key is valid
*/
async validateDextoApiKey(apiKey: string): Promise<boolean> {
try {
logger.debug('Validating DEXTO_API_KEY');
const response = await fetch(`${this.baseUrl}/keys/validate`, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
return false;
}
const result: { valid: boolean } = await response.json();
return result.valid;
} catch (error) {
logger.error(`Error validating Dexto API key: ${error}`);
return false;
}
}
/**
* Provision Dexto API key (get existing or create new with given name)
* @param regenerate - If true, delete existing key and create new one
*/
async provisionDextoApiKey(
authToken: string,
name: string = 'Dexto CLI Key',
regenerate: boolean = false
): Promise<{ dextoApiKey: string; keyId: string; isNewKey: boolean }> {
try {
logger.debug(
`Provisioning DEXTO_API_KEY with name: ${name}, regenerate: ${regenerate}`
);
const response = await fetch(`${this.baseUrl}/keys/provision`, {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, regenerate }),
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API request failed: ${response.status} ${errorText}`);
}
const result: ProvisionResponse = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to provision Dexto API key');
}
if (!result.keyId) {
throw new Error('Invalid response from API');
}
// If isNewKey is false, the key already exists (we don't get the key value back)
// This is expected - the key was already provisioned
if (!result.isNewKey && !result.dextoApiKey) {
logger.debug(`DEXTO_API_KEY already exists: ${result.keyId}`);
return {
dextoApiKey: '', // Empty - key already exists, not returned for security
keyId: result.keyId,
isNewKey: false,
};
}
if (!result.dextoApiKey) {
throw new Error('Invalid response from API - missing key');
}
logger.debug(`Successfully provisioned DEXTO_API_KEY: ${result.keyId}`);
return {
dextoApiKey: result.dextoApiKey,
keyId: result.keyId,
isNewKey: result.isNewKey ?? true,
};
} catch (error) {
logger.error(`Error provisioning Dexto API key: ${error}`);
throw error;
}
}
/**
* Get usage summary (balance + MTD usage + recent history)
*/
async getUsageSummary(apiKey: string): Promise<UsageSummaryResponse> {
try {
logger.debug('Fetching usage summary');
const response = await fetch(`${this.baseUrl}/me/usage`, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API request failed: ${response.status} ${errorText}`);
}
const result: UsageSummaryResponse = await response.json();
return result;
} catch (error) {
logger.error(`Error fetching usage summary: ${error}`);
throw error;
}
}
}
/**
* Get default Dexto API client
*/
export function getDextoApiClient(): DextoApiClient {
return new DextoApiClient();
}

View File

@@ -0,0 +1,27 @@
// packages/cli/src/cli/auth/constants.ts
/**
* Dexto's Supabase configuration for CLI authentication.
*
* SECURITY NOTE:
* The Supabase anon key is safe to hardcode in distributed code because:
* 1. It's designed for client-side use (web browsers, mobile apps, CLIs)
* 2. It only grants anonymous access - real security is enforced by Row Level Security (RLS)
* 3. This is standard practice (Vercel CLI, Supabase CLI, Firebase CLI all do the same)
*
* The service role key (which has admin access) is NEVER in this codebase.
*
* Environment variable overrides (for local development):
* - SUPABASE_URL: Override Supabase URL (e.g., http://localhost:54321)
* - SUPABASE_ANON_KEY: Override anon key (from `supabase start` output)
* - DEXTO_API_URL: Override Dexto API URL (e.g., http://localhost:3001)
*/
export const SUPABASE_URL = process.env.SUPABASE_URL || 'https://gdfbxznhnnsamvsrtwjq.supabase.co';
export const SUPABASE_ANON_KEY =
process.env.SUPABASE_ANON_KEY ||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImdkZmJ4em5obm5zYW12c3J0d2pxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQwNjkzNjksImV4cCI6MjA3OTY0NTM2OX0.j2NWOJDOy8gTT84XeomalkGSPpLdPvTCBnQMrTgdlI4';
/**
* Dexto API URL for key provisioning
*/
export const DEXTO_API_URL = process.env.DEXTO_API_URL || 'https://api.dexto.ai';

View File

@@ -0,0 +1,19 @@
// packages/cli/src/cli/auth/index.ts
// Public exports for auth module
export {
type AuthConfig,
storeAuth,
loadAuth,
removeAuth,
isAuthenticated,
getAuthToken,
getDextoApiKey,
getAuthFilePath,
} from './service.js';
export { type OAuthResult, performOAuthLogin, DEFAULT_OAUTH_CONFIG } from './oauth.js';
export { type UsageSummaryResponse, DextoApiClient, getDextoApiClient } from './api-client.js';
export { SUPABASE_URL, SUPABASE_ANON_KEY, DEXTO_API_URL } from './constants.js';

View File

@@ -0,0 +1,376 @@
// packages/cli/src/cli/auth/oauth.ts
// OAuth flow implementation with local callback server
//
// TODO: Add CSRF protection via Device Code Flow (RFC 8628)
// Current localhost callback pattern lacks CSRF protection. We attempted to add
// a `state` parameter but Supabase uses `state` internally for its own OAuth CSRF
// with Google, causing conflicts. The proper solution is Device Code Flow:
// 1. CLI requests device code from server
// 2. User visits URL and enters code
// 3. CLI polls for authorization completion
// This is what Supabase CLI, GitHub CLI, and others use for secure CLI auth.
import * as http from 'http';
import * as url from 'url';
import * as querystring from 'querystring';
import chalk from 'chalk';
import * as p from '@clack/prompts';
import { logger } from '@dexto/core';
import { readFileSync } from 'node:fs';
import { SUPABASE_URL, SUPABASE_ANON_KEY } from './constants.js';
// Track active OAuth callback servers by port for cleanup
const oauthStateStore = new Map<number, string>();
const DEXTO_LOGO_DATA_URL = (() => {
try {
const svg = readFileSync(new URL('../assets/dexto-logo.svg', import.meta.url), 'utf-8');
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
} catch (error) {
logger.warn(
`Failed to load Dexto logo asset for OAuth screen: ${error instanceof Error ? error.message : String(error)}`
);
return '';
}
})();
const LOGO_FALLBACK_TEXT = DEXTO_LOGO_DATA_URL ? '' : 'D';
const LOGO_HTML = `<div class="logo">${LOGO_FALLBACK_TEXT}</div>`;
// Pre-generate HTML strings with logo
const ERROR_HTML = `${LOGO_HTML}<div class="error-icon">✕</div><h1 class="error-title">Authentication Failed</h1><p>You can close this window and try again in your terminal.</p>`;
const SUCCESS_HTML = `${LOGO_HTML}<div class="success-icon">✓</div><h1 class="success-title">Login Successful!</h1><p>Welcome to Dexto! You can close this window and return to your terminal.</p>`;
const NO_DATA_HTML = `${LOGO_HTML}<div class="error-icon">✕</div><h1 class="error-title">No Authentication Data</h1><p>Please try the login process again in your terminal.</p>`;
interface OAuthConfig {
authUrl: string;
clientId: string;
provider?: string;
scopes?: string[];
}
export interface OAuthResult {
accessToken: string;
refreshToken?: string | undefined;
expiresIn?: number | undefined;
user?:
| {
id: string;
email: string;
name?: string | undefined;
}
| undefined;
}
// Fixed port for OAuth callback - must match Supabase redirect URL config
const OAUTH_CALLBACK_PORT = 48102;
/**
* Check if fixed port is available, throw if not
*/
function ensurePortAvailable(port: number): Promise<number> {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.listen(port, () => {
server.close(() => resolve(port));
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
reject(
new Error(
`Port ${port} is already in use. Please close the application using it and try again.`
)
);
} else {
reject(err);
}
});
});
}
/**
* Start local HTTP server to receive OAuth callback
*/
function startCallbackServer(port: number, config: OAuthConfig): Promise<OAuthResult> {
return new Promise((resolve, reject) => {
const server = http.createServer(async (req, res) => {
try {
if (!req.url) {
res.writeHead(400);
res.end('Bad Request');
return;
}
const parsedUrl = url.parse(req.url, true);
if (req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<head>
<meta charset="utf-8" />
<title>Dexto Authentication</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Geist', -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
color: #fafafa;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: #141414;
border: 1px solid #262626;
padding: 48px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
max-width: 450px;
width: 100%;
text-align: center;
}
.logo {
width: 240px;
height: 70px;
margin: 0 auto 32px;
background-image: ${DEXTO_LOGO_DATA_URL ? `url("${DEXTO_LOGO_DATA_URL}")` : 'none'};
background-repeat: no-repeat;
background-position: center;
background-size: contain;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 600;
color: #4ec9b0;
}
.spinner {
font-size: 48px;
margin-bottom: 24px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
h1 { font-size: 24px; font-weight: 600; margin-bottom: 12px; color: #fafafa; }
p { color: #a1a1aa; font-size: 16px; line-height: 1.5; }
.success-icon { color: #22c55e; font-size: 64px; margin-bottom: 24px; }
.error-icon { color: #dc2626; font-size: 64px; margin-bottom: 24px; }
.success-title { color: #22c55e; }
.error-title { color: #dc2626; }
</style>
</head>
<body>
<div class="container">
${LOGO_HTML}
<div class="spinner">◐</div>
<h1>Processing Authentication...</h1>
<p>Please wait while we complete your Dexto login.</p>
</div>
<script>
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const urlParams = new URLSearchParams(window.location.search);
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
const refreshToken = hashParams.get('refresh_token') || urlParams.get('refresh_token');
const expiresIn = hashParams.get('expires_in') || urlParams.get('expires_in');
const error = hashParams.get('error') || urlParams.get('error');
if (window.location.hash || window.location.search) {
window.history.replaceState(null, document.title, window.location.pathname);
}
if (error) {
fetch('/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: error })
}).then(() => {
document.querySelector('.container').innerHTML = ${JSON.stringify(ERROR_HTML)};
});
} else if (accessToken) {
fetch('/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_token: accessToken,
refresh_token: refreshToken,
expires_in: expiresIn ? parseInt(expiresIn) : undefined
})
}).then(() => {
document.querySelector('.container').innerHTML = ${JSON.stringify(SUCCESS_HTML)};
});
} else {
document.querySelector('.container').innerHTML = ${JSON.stringify(NO_DATA_HTML)};
}
</script>
</body>
</html>
`);
} else if (req.method === 'POST' && parsedUrl.pathname === '/callback') {
let body = '';
const MAX_BODY_SIZE = 10 * 1024; // 10KB - plenty for OAuth tokens
req.on('data', (chunk) => {
if (body.length + chunk.length > MAX_BODY_SIZE) {
req.destroy();
res.writeHead(413);
res.end('Request too large');
return;
}
body += chunk.toString();
});
req.on('end', async () => {
try {
const data = JSON.parse(body);
if (data.error) {
res.writeHead(200);
res.end('OK');
server.close();
reject(new Error(`OAuth error: ${data.error}`));
return;
}
if (data.access_token) {
const userResponse = await fetch(`${config.authUrl}/auth/v1/user`, {
headers: {
Authorization: `Bearer ${data.access_token}`,
apikey: config.clientId,
},
});
const userData = userResponse.ok ? await userResponse.json() : null;
const result: OAuthResult = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
user: userData
? {
id: userData.id,
email: userData.email,
name:
userData.user_metadata?.full_name ||
userData.email,
}
: undefined,
};
res.writeHead(200);
res.end('OK');
server.close();
resolve(result);
} else {
res.writeHead(400);
res.end('Missing tokens');
server.close();
reject(new Error('No access token received'));
}
} catch (_err) {
res.writeHead(400);
res.end('Invalid data');
server.close();
reject(new Error('Invalid callback data'));
}
});
} else {
res.writeHead(404);
res.end('Not Found');
}
} catch (error) {
logger.error(`Callback server error: ${error}`);
res.writeHead(500);
res.end('Internal Server Error');
server.close();
oauthStateStore.delete(port);
reject(error);
}
});
const timeoutMs = 5 * 60 * 1000;
const timeoutHandle = setTimeout(() => {
server.close();
oauthStateStore.delete(port);
reject(new Error('Authentication timed out'));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeoutHandle);
oauthStateStore.delete(port);
};
server.listen(port, 'localhost', () => {
logger.debug(`OAuth callback server listening on http://localhost:${port}`);
});
server.on('close', cleanup);
server.on('error', (error) => {
cleanup();
reject(new Error(`Failed to start callback server: ${error.message}`));
});
});
}
/**
* Perform OAuth login flow with Supabase
*/
export async function performOAuthLogin(config: OAuthConfig): Promise<OAuthResult> {
try {
const port = await ensurePortAvailable(OAUTH_CALLBACK_PORT);
const redirectUri = `http://localhost:${port}`;
oauthStateStore.set(port, 'active');
logger.debug(`Registered OAuth callback server on port ${port}`);
const provider = config.provider || 'google';
const authParams = querystring.stringify({
redirect_to: redirectUri,
});
const authUrl = `${config.authUrl}/auth/v1/authorize?provider=${provider}&${authParams}`;
const tokenPromise = startCallbackServer(port, config);
console.log(chalk.cyan('🌐 Opening browser for authentication...'));
try {
const { default: open } = await import('open');
await open(authUrl);
console.log(chalk.green('✅ Browser opened'));
} catch (_error) {
console.log(chalk.yellow(`💡 Please open manually: ${authUrl}`));
}
const spinner = p.spinner();
spinner.start('Waiting for authentication...');
try {
const result = await tokenPromise;
spinner.stop('Authentication successful!');
return result;
} catch (error) {
spinner.stop('Authentication failed');
throw error;
}
} catch (_error) {
throw new Error(
`OAuth login failed: ${_error instanceof Error ? _error.message : String(_error)}`
);
}
}
/**
* Default Supabase OAuth configuration for Dexto CLI
*/
export const DEFAULT_OAUTH_CONFIG: OAuthConfig = {
authUrl: SUPABASE_URL,
clientId: SUPABASE_ANON_KEY,
provider: 'google',
scopes: ['openid', 'email', 'profile'],
};

View File

@@ -0,0 +1,196 @@
// packages/cli/src/cli/auth/service.ts
// Auth storage, token management, and refresh logic
import chalk from 'chalk';
import { existsSync, promises as fs } from 'fs';
import { z } from 'zod';
import { getDextoGlobalPath, logger } from '@dexto/core';
import { SUPABASE_URL, SUPABASE_ANON_KEY } from './constants.js';
const AUTH_CONFIG_FILE = 'auth.json';
export interface AuthConfig {
/** Supabase access token from OAuth login (optional if using --api-key) */
token?: string | undefined;
refreshToken?: string | undefined;
userId?: string | undefined;
email?: string | undefined;
expiresAt?: number | undefined;
createdAt: number;
/** Dexto API key for gateway access (from --api-key or provisioned after OAuth) */
dextoApiKey?: string | undefined;
dextoKeyId?: string | undefined;
}
const AuthConfigSchema = z
.object({
token: z.string().min(1).optional(),
refreshToken: z.string().optional(),
userId: z.string().optional(),
email: z.string().email().optional(),
expiresAt: z.number().optional(),
createdAt: z.number(),
dextoApiKey: z.string().optional(),
dextoKeyId: z.string().optional(),
})
.refine((data) => data.token || data.dextoApiKey, {
message: 'Either token (from OAuth) or dextoApiKey (from --api-key) is required',
});
export async function storeAuth(config: AuthConfig): Promise<void> {
const authPath = getDextoGlobalPath('', AUTH_CONFIG_FILE);
const dextoDir = getDextoGlobalPath('', '');
await fs.mkdir(dextoDir, { recursive: true });
await fs.writeFile(authPath, JSON.stringify(config, null, 2), { mode: 0o600 });
logger.debug(`Stored auth config at: ${authPath}`);
}
export async function loadAuth(): Promise<AuthConfig | null> {
const authPath = getDextoGlobalPath('', AUTH_CONFIG_FILE);
if (!existsSync(authPath)) {
return null;
}
try {
const content = await fs.readFile(authPath, 'utf-8');
const config = JSON.parse(content);
const validated = AuthConfigSchema.parse(config);
if (validated.expiresAt && validated.expiresAt < Date.now()) {
// Only remove auth if there's no refresh token available
// If refresh token exists, return the expired auth and let getAuthToken() handle refresh
if (!validated.refreshToken) {
await removeAuth();
return null;
}
}
return validated;
} catch (error) {
logger.warn(`Invalid auth config, removing: ${error}`);
await removeAuth();
return null;
}
}
export async function removeAuth(): Promise<void> {
const authPath = getDextoGlobalPath('', AUTH_CONFIG_FILE);
if (existsSync(authPath)) {
await fs.unlink(authPath);
logger.debug(`Removed auth config from: ${authPath}`);
}
}
export async function isAuthenticated(): Promise<boolean> {
const auth = await loadAuth();
return auth !== null;
}
export async function getAuthToken(): Promise<string | null> {
const auth = await loadAuth();
if (!auth) {
return null;
}
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
const isExpiringSoon = auth.expiresAt && auth.expiresAt < now + fiveMinutes;
if (!isExpiringSoon) {
return auth.token ?? null;
}
if (!auth.refreshToken) {
logger.debug('Token expired but no refresh token available');
await removeAuth();
return null;
}
logger.debug('Access token expired or expiring soon, refreshing...');
console.log(chalk.cyan('🔄 Access token expiring soon, refreshing...'));
const refreshResult = await refreshAccessToken(auth.refreshToken);
if (!refreshResult) {
logger.debug('Token refresh failed, removing auth');
console.log(chalk.red('❌ Token refresh failed. Please login again.'));
await removeAuth();
return null;
}
const newExpiresAt = Date.now() + refreshResult.expiresIn * 1000;
await storeAuth({
...auth,
token: refreshResult.accessToken,
refreshToken: refreshResult.refreshToken,
expiresAt: newExpiresAt,
});
logger.debug('Token refreshed successfully');
console.log(chalk.green('✅ Access token refreshed successfully'));
return refreshResult.accessToken;
}
export async function getDextoApiKey(): Promise<string | null> {
// Explicit env var takes priority (for CI, testing, account override)
if (process.env.DEXTO_API_KEY?.trim()) {
return process.env.DEXTO_API_KEY;
}
// Fall back to auth.json (from `dexto login`)
const auth = await loadAuth();
return auth?.dextoApiKey || null;
}
async function refreshAccessToken(refreshToken: string): Promise<{
accessToken: string;
refreshToken: string;
expiresIn: number;
} | null> {
try {
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: SUPABASE_ANON_KEY,
},
body: JSON.stringify({
refresh_token: refreshToken,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
logger.debug(`Token refresh failed: ${response.status}`);
return null;
}
const data = await response.json();
if (!data.access_token || !data.refresh_token) {
logger.debug('Token refresh response missing required tokens');
return null;
}
logger.debug('Successfully refreshed access token');
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in || 3600,
};
} catch (error) {
logger.debug(
`Token refresh error: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
export function getAuthFilePath(): string {
return getDextoGlobalPath('', AUTH_CONFIG_FILE);
}

View File

@@ -0,0 +1,251 @@
/**
* CLI Event Subscriber for headless mode
* Handles agent events and outputs to stdout/stderr
*
* Simple, composable output suitable for piping and scripting
* No TUI, no boxes - just clean text output
*/
import { logger, DextoAgent } from '@dexto/core';
import { EventSubscriber } from '@dexto/server';
import { AgentEventBus } from '@dexto/core';
import type { SanitizedToolResult, AgentEventMap } from '@dexto/core';
import { capture } from '../analytics/index.js';
/**
* Event subscriber for CLI headless mode
* Implements the standard EventSubscriber pattern used throughout the codebase
*/
export class CLISubscriber implements EventSubscriber {
private streamingContent: string = '';
private completionResolve?: () => void;
private completionReject?: (error: Error) => void;
subscribe(eventBus: AgentEventBus): void {
eventBus.on('llm:thinking', this.onThinking.bind(this));
eventBus.on('llm:chunk', (payload) => {
if (payload.chunkType === 'text') {
this.onChunk(payload.content);
}
// Ignore reasoning chunks for headless mode
});
eventBus.on('llm:tool-call', (payload) => this.onToolCall(payload.toolName, payload.args));
eventBus.on('llm:tool-result', (payload) => {
// Only call onToolResult when we have sanitized result (success case)
if (payload.sanitized) {
this.onToolResult(
payload.toolName,
payload.sanitized,
payload.rawResult,
payload.success
);
}
// For error case (success=false), the error is handled via llm:error event
});
eventBus.on('llm:response', (payload) => {
this.onResponse(payload.content);
this.captureTokenUsage(payload);
});
eventBus.on('llm:error', (payload) => this.onError(payload.error));
eventBus.on('session:reset', this.onConversationReset.bind(this));
eventBus.on('context:compacting', this.onContextCompacting.bind(this));
eventBus.on('context:compacted', this.onContextCompacted.bind(this));
}
/**
* Clean up internal state
* Called when the CLI subscriber is being disposed of
*/
cleanup(): void {
this.streamingContent = '';
// Reject any pending promises to prevent resource leaks
if (this.completionReject) {
const reject = this.completionReject;
delete this.completionResolve;
delete this.completionReject;
reject(new Error('CLI subscriber cleaned up while operation pending'));
}
logger.debug('CLI event subscriber cleaned up');
}
onThinking(): void {
// Silent in headless mode - no "thinking..." messages
}
onChunk(text: string): void {
// Stream directly to stdout for real-time output
this.streamingContent += text;
process.stdout.write(text);
}
onToolCall(toolName: string, _args: any): void {
// Simple tool indicator to stderr (doesn't interfere with stdout)
process.stderr.write(`[Tool: ${toolName}]\n`);
}
onToolResult(
toolName: string,
sanitized: SanitizedToolResult,
rawResult?: unknown,
success?: boolean
): void {
// Simple completion indicator to stderr
const status = success ? '✓' : '✗';
process.stderr.write(`[${status}] ${toolName} complete\n`);
}
onResponse(text: string): void {
// If we didn't stream anything (no chunks), output the full response now
if (!this.streamingContent) {
process.stdout.write(text);
if (!text.endsWith('\n')) {
process.stdout.write('\n');
}
} else {
// We already streamed the content, just add newline if needed
if (!this.streamingContent.endsWith('\n')) {
process.stdout.write('\n');
}
}
// Clear accumulated state
this.streamingContent = '';
// Resolve completion promise if waiting
if (this.completionResolve) {
const resolve = this.completionResolve;
delete this.completionResolve;
delete this.completionReject;
resolve();
}
}
onError(error: Error): void {
// Clear any partial response state
this.streamingContent = '';
// Show error to stderr for immediate user feedback
console.error(`❌ Error: ${error.message}`);
// Show recovery guidance if available (for DextoRuntimeError)
if ('recovery' in error && error.recovery) {
const recoveryMessages = Array.isArray(error.recovery)
? error.recovery
: [error.recovery];
console.error('');
recoveryMessages.forEach((msg) => {
console.error(`💡 ${msg}`);
});
}
// Show stack for debugging if available
if (error.stack) {
console.error('');
console.error(error.stack);
}
// Log details to file
logger.error(`Error: ${error.message}`, {
stack: error.stack,
name: error.name,
cause: error.cause,
recovery: 'recovery' in error ? error.recovery : undefined,
});
// Reject completion promise if waiting
if (this.completionReject) {
const reject = this.completionReject;
delete this.completionResolve;
delete this.completionReject;
reject(error);
}
}
onConversationReset(): void {
// Clear any partial response state
this.streamingContent = '';
logger.info('🔄 Conversation history cleared.', null, 'blue');
}
onContextCompacting(payload: AgentEventMap['context:compacting']): void {
// Output to stderr (doesn't interfere with stdout response stream)
process.stderr.write(
`[📦 Compacting context (~${payload.estimatedTokens.toLocaleString()} tokens)...]\n`
);
}
onContextCompacted(payload: AgentEventMap['context:compacted']): void {
const { originalTokens, compactedTokens, originalMessages, compactedMessages, reason } =
payload;
const reductionPercent =
originalTokens > 0
? Math.round(((originalTokens - compactedTokens) / originalTokens) * 100)
: 0;
// Output to stderr (doesn't interfere with stdout response stream)
process.stderr.write(
`[📦 Context compacted (${reason}): ${originalTokens.toLocaleString()} → ~${compactedTokens.toLocaleString()} tokens (${reductionPercent}% reduction), ${originalMessages}${compactedMessages} messages]\n`
);
}
/**
* Capture LLM token usage analytics
*/
private captureTokenUsage(payload: AgentEventMap['llm:response']): void {
const { tokenUsage, provider, model, sessionId, estimatedInputTokens } = payload;
if (!tokenUsage || (!tokenUsage.inputTokens && !tokenUsage.outputTokens)) {
return;
}
// Calculate estimate accuracy if both estimate and actual are available
let estimateAccuracyPercent: number | undefined;
if (estimatedInputTokens !== undefined && tokenUsage.inputTokens) {
const diff = estimatedInputTokens - tokenUsage.inputTokens;
estimateAccuracyPercent = Math.round((diff / tokenUsage.inputTokens) * 100);
}
capture('dexto_llm_tokens_consumed', {
source: 'cli',
sessionId,
provider,
model,
inputTokens: tokenUsage.inputTokens,
outputTokens: tokenUsage.outputTokens,
reasoningTokens: tokenUsage.reasoningTokens,
totalTokens: tokenUsage.totalTokens,
cacheReadTokens: tokenUsage.cacheReadTokens,
cacheWriteTokens: tokenUsage.cacheWriteTokens,
estimatedInputTokens,
estimateAccuracyPercent,
});
}
/**
* Run agent in headless mode and wait for completion
* Returns a promise that resolves when the response is complete
*/
async runAndWait(agent: DextoAgent, prompt: string, sessionId: string): Promise<void> {
// Prevent concurrent calls
if (this.completionResolve || this.completionReject) {
throw new Error('Cannot call runAndWait while another operation is pending');
}
return new Promise((resolve, reject) => {
this.completionResolve = resolve;
this.completionReject = reject;
// Execute the prompt
agent.run(prompt, undefined, undefined, sessionId).catch((error) => {
// If agent.run() rejects but we haven't already rejected via event
if (this.completionReject) {
const rejectHandler = this.completionReject;
delete this.completionResolve;
delete this.completionReject;
rejectHandler(error);
}
});
});
}
}

View File

@@ -0,0 +1,5 @@
// packages/cli/src/cli/commands/auth/index.ts
export { handleLoginCommand, handleBrowserLogin } from './login.js';
export { handleLogoutCommand } from './logout.js';
export { handleStatusCommand } from './status.js';

View File

@@ -0,0 +1,314 @@
// packages/cli/src/cli/commands/auth/login.ts
import chalk from 'chalk';
import * as p from '@clack/prompts';
import {
isAuthenticated,
loadAuth,
storeAuth,
getDextoApiClient,
SUPABASE_URL,
SUPABASE_ANON_KEY,
} from '../../auth/index.js';
import { logger } from '@dexto/core';
/**
* Handle login command - multiple methods supported
*/
export async function handleLoginCommand(
options: {
apiKey?: string;
interactive?: boolean;
} = {}
): Promise<void> {
try {
if (await isAuthenticated()) {
const auth = await loadAuth();
const userInfo = auth?.email || auth?.userId || 'user';
console.log(chalk.green(`✅ Already logged in as: ${userInfo}`));
// In non-interactive mode, already authenticated = success (idempotent)
if (options.interactive === false) {
return;
}
const shouldContinue = await p.confirm({
message: 'Do you want to login with a different account?',
initialValue: false,
});
if (p.isCancel(shouldContinue) || !shouldContinue) {
return;
}
}
if (options.apiKey) {
// Validate the Dexto API key before storing
const client = getDextoApiClient();
const isValid = await client.validateDextoApiKey(options.apiKey);
if (!isValid) {
throw new Error('Invalid API key provided - validation failed');
}
await storeAuth({ dextoApiKey: options.apiKey, createdAt: Date.now() });
console.log(chalk.green('✅ Dexto API key saved'));
return;
}
if (options.interactive === false) {
throw new Error('--api-key is required when --no-interactive is used');
}
p.intro(chalk.inverse(' Login to Dexto '));
console.log(chalk.dim('This will open your browser for authentication.'));
const shouldUseOAuth = await p.confirm({
message: 'Continue with browser authentication?',
initialValue: true,
});
if (p.isCancel(shouldUseOAuth)) {
p.cancel('Login cancelled');
return;
}
if (shouldUseOAuth) {
await handleBrowserLogin();
} else {
console.log(chalk.dim('\nAlternatively, you can enter a token manually:'));
await handleTokenLogin();
}
p.outro(chalk.green('🎉 Login successful!'));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
p.outro(chalk.red(`❌ Login failed: ${errorMessage}`));
// Re-throw to let CLI wrapper handle exit and analytics tracking
throw error;
}
}
export async function handleBrowserLogin(): Promise<void> {
const { performOAuthLogin, DEFAULT_OAUTH_CONFIG } = await import('../../auth/oauth.js');
try {
const result = await performOAuthLogin(DEFAULT_OAUTH_CONFIG);
const expiresAt = result.expiresIn ? Date.now() + result.expiresIn * 1000 : undefined;
await storeAuth({
token: result.accessToken,
refreshToken: result.refreshToken,
userId: result.user?.id,
email: result.user?.email,
createdAt: Date.now(),
expiresAt,
});
if (result.user?.email) {
console.log(chalk.dim(`\nWelcome back, ${result.user.email}`));
}
await provisionKeys(result.accessToken, result.user?.email);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('timed out')) {
throw new Error('Login timed out. Please try again.');
} else if (errorMessage.includes('user denied')) {
throw new Error('Login was cancelled.');
} else {
throw new Error(`Login failed: ${errorMessage}`);
}
}
}
async function handleTokenLogin(): Promise<void> {
const token = await p.password({
message: 'Enter your API token:',
validate: (value) => {
if (!value) return 'Token is required';
if (value.length < 10) return 'Token seems too short';
return undefined;
},
});
if (p.isCancel(token)) {
p.cancel('Token entry cancelled');
return;
}
const spinner = p.spinner();
spinner.start('Verifying token...');
try {
const isValid = await verifyToken(token as string);
if (!isValid) {
spinner.stop('Invalid token');
throw new Error('Token verification failed');
}
spinner.stop('Token verified!');
await storeAuth({
token: token as string,
createdAt: Date.now(),
});
// Provision Dexto API key for gateway access
await provisionKeys(token as string);
} catch (error) {
spinner.stop('Verification failed');
throw error;
}
}
async function verifyToken(token: string): Promise<boolean> {
try {
const response = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: {
Authorization: `Bearer ${token}`,
apikey: SUPABASE_ANON_KEY,
'User-Agent': 'dexto-cli/1.0.0',
},
signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
const userData = await response.json();
return !!userData.id;
}
return false;
} catch (error) {
logger.debug(
`Token verification failed: ${error instanceof Error ? error.message : String(error)}`
);
return false;
}
}
/**
* Helper to save Dexto API key to ~/.dexto/.env
* This ensures the key is available for the layered env loading at startup.
*/
async function saveDextoApiKey(apiKey: string): Promise<void> {
const { getDextoEnvPath, ensureDextoGlobalDirectory } = await import('@dexto/core');
const path = await import('path');
const fs = await import('fs/promises');
const envVar = 'DEXTO_API_KEY';
const targetEnvPath = getDextoEnvPath();
// Ensure directory exists
await ensureDextoGlobalDirectory();
await fs.mkdir(path.dirname(targetEnvPath), { recursive: true });
// Read existing .env or create empty
let envContent = '';
try {
envContent = await fs.readFile(targetEnvPath, 'utf-8');
} catch {
// File doesn't exist, start fresh
}
// Update or add the key
const lines = envContent.split('\n');
const keyPattern = new RegExp(`^${envVar}=`);
const keyIndex = lines.findIndex((line) => keyPattern.test(line));
if (keyIndex >= 0) {
lines[keyIndex] = `${envVar}=${apiKey}`;
} else {
lines.push(`${envVar}=${apiKey}`);
}
// Write back
await fs.writeFile(targetEnvPath, lines.filter(Boolean).join('\n') + '\n', 'utf-8');
// Make available in current process immediately
process.env[envVar] = apiKey;
}
async function provisionKeys(authToken: string, _userEmail?: string): Promise<void> {
try {
const apiClient = await getDextoApiClient();
const auth = await loadAuth();
// 1. Check if we already have a local key
if (auth?.dextoApiKey) {
console.log(chalk.cyan('🔍 Validating existing API key...'));
try {
const isValid = await apiClient.validateDextoApiKey(auth.dextoApiKey);
if (isValid) {
console.log(chalk.green('✅ Existing key is valid'));
// Ensure .env is in sync
await saveDextoApiKey(auth.dextoApiKey);
return; // All good, we're done
}
// Key is invalid - need new one
console.log(chalk.yellow('⚠️ Existing key is invalid, provisioning new one...'));
const provisionResult = await apiClient.provisionDextoApiKey(authToken);
if (!provisionResult.dextoApiKey) {
throw new Error('Failed to get new API key');
}
await storeAuth({
...auth,
dextoApiKey: provisionResult.dextoApiKey,
dextoKeyId: provisionResult.keyId,
});
await saveDextoApiKey(provisionResult.dextoApiKey);
console.log(chalk.green('✅ New key provisioned'));
console.log(chalk.dim(` Key ID: ${provisionResult.keyId}`));
return;
} catch (error) {
// Validation or rotation failed - this is a critical error
logger.warn(`Key validation/rotation failed: ${error}`);
throw new Error(
`Failed to validate or rotate key: ${error instanceof Error ? error.message : String(error)}`
);
}
}
// 2. No local key - provision one
console.log(chalk.cyan('🔑 Provisioning Dexto API key...'));
let provisionResult = await apiClient.provisionDextoApiKey(authToken);
if (!auth) {
throw new Error('Authentication state not found');
}
// If key already exists server-side but we don't have it locally, regenerate it
if (!provisionResult.isNewKey) {
console.log(
chalk.yellow('⚠️ CLI key exists on server but not locally, regenerating...')
);
provisionResult = await apiClient.provisionDextoApiKey(
authToken,
'Dexto CLI Key',
true
);
}
await storeAuth({
...auth,
dextoApiKey: provisionResult.dextoApiKey,
dextoKeyId: provisionResult.keyId,
});
await saveDextoApiKey(provisionResult.dextoApiKey);
console.log(chalk.green('✅ Dexto API key provisioned!'));
console.log(chalk.dim(` Key ID: ${provisionResult.keyId}`));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(chalk.red(`❌ Failed to provision Dexto API key: ${errorMessage}`));
console.log(chalk.dim(' You can still use Dexto with your own API keys'));
logger.warn(`Provisioning failed: ${errorMessage}`);
// Don't throw - login should still succeed even if key provisioning fails
}
}

View File

@@ -0,0 +1,73 @@
// packages/cli/src/cli/commands/auth/logout.ts
import chalk from 'chalk';
import * as p from '@clack/prompts';
import { isAuthenticated, removeAuth } from '../../auth/index.js';
import { isUsingDextoCredits } from '../../../config/effective-llm.js';
import { logger } from '@dexto/core';
export async function handleLogoutCommand(
options: {
force?: boolean;
interactive?: boolean;
} = {}
): Promise<void> {
try {
if (!(await isAuthenticated())) {
console.log(chalk.yellow(' Not currently logged in'));
return;
}
// Check if user is configured to use Dexto credits
// Uses getEffectiveLLMConfig() to check all config layers
const usingDextoCredits = await isUsingDextoCredits();
if (options.interactive !== false && !options.force) {
p.intro(chalk.inverse(' Logout '));
// Warn if using Dexto credits
if (usingDextoCredits) {
console.log(
chalk.yellow(
'\n⚠ You are currently configured to use Dexto credits (provider: dexto)'
)
);
console.log(
chalk.dim(' After logout, you will need to run `dexto setup` to configure')
);
console.log(
chalk.dim(' a different provider, or `dexto login` to log back in.\n')
);
}
const shouldLogout = await p.confirm({
message: usingDextoCredits
? 'Logout will disable Dexto credits. Continue?'
: 'Are you sure you want to logout?',
initialValue: false,
});
if (p.isCancel(shouldLogout) || !shouldLogout) {
p.cancel('Logout cancelled');
return;
}
}
await removeAuth();
console.log(chalk.green('✅ Successfully logged out'));
if (usingDextoCredits) {
console.log();
console.log(chalk.cyan('Next steps:'));
console.log(chalk.dim(' • Run `dexto login` to log back in'));
console.log(chalk.dim(' • Or run `dexto setup` to configure a different provider'));
}
logger.info('User logged out');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(chalk.red(`❌ Logout failed: ${errorMessage}`));
// Re-throw to let CLI wrapper handle exit and analytics tracking
throw error;
}
}

View File

@@ -0,0 +1,29 @@
// packages/cli/src/cli/commands/auth/status.ts
import chalk from 'chalk';
import { loadAuth } from '../../auth/index.js';
export async function handleStatusCommand(): Promise<void> {
const auth = await loadAuth();
if (!auth) {
console.log(chalk.yellow('❌ Not logged in'));
console.log(chalk.dim('Run `dexto login` to authenticate'));
return;
}
console.log(chalk.green('✅ Logged in'));
if (auth.email) {
console.log(chalk.dim(`Email: ${auth.email}`));
}
if (auth.userId) {
console.log(chalk.dim(`User ID: ${auth.userId}`));
}
if (auth.expiresAt) {
const expiresDate = new Date(auth.expiresAt);
console.log(chalk.dim(`Expires: ${expiresDate.toLocaleDateString()}`));
}
}

View File

@@ -0,0 +1,3 @@
// packages/cli/src/cli/commands/billing/index.ts
export { handleBillingStatusCommand } from './status.js';

View File

@@ -0,0 +1,75 @@
// packages/cli/src/cli/commands/billing/status.ts
import chalk from 'chalk';
import { loadAuth, getDextoApiClient } from '../../auth/index.js';
/**
* Handle the `dexto billing` command.
* Shows Dexto account billing information including balance and usage.
*/
export async function handleBillingStatusCommand(): Promise<void> {
const auth = await loadAuth();
if (!auth) {
console.log(chalk.yellow('❌ Not logged in to Dexto'));
console.log(chalk.dim('Run `dexto login` to authenticate'));
return;
}
if (!auth.dextoApiKey) {
console.log(chalk.yellow('❌ No Dexto API key found'));
console.log(chalk.dim('Run `dexto login` to provision an API key'));
return;
}
console.log(chalk.green('✅ Logged in to Dexto'));
if (auth.email) {
console.log(chalk.dim(`Account: ${auth.email}`));
}
console.log();
try {
const apiClient = getDextoApiClient();
const usage = await apiClient.getUsageSummary(auth.dextoApiKey);
// Display balance
console.log(chalk.cyan('💰 Balance'));
console.log(` ${chalk.bold('$' + usage.credits_usd.toFixed(2))} remaining`);
console.log();
// Display month-to-date usage
console.log(chalk.cyan('📊 This Month'));
console.log(` Spent: ${chalk.yellow('$' + usage.mtd_usage.total_cost_usd.toFixed(4))}`);
console.log(` Requests: ${chalk.yellow(usage.mtd_usage.total_requests.toString())}`);
// Show usage by model if there's any
const modelEntries = Object.entries(usage.mtd_usage.by_model);
if (modelEntries.length > 0) {
console.log();
console.log(chalk.cyan('📈 Usage by Model'));
for (const [model, stats] of modelEntries) {
console.log(
` ${chalk.dim(model)}: $${stats.cost_usd.toFixed(4)} (${stats.requests} requests)`
);
}
}
// Show recent usage if any
if (usage.recent.length > 0) {
console.log();
console.log(chalk.cyan('🕐 Recent Activity'));
for (const entry of usage.recent.slice(0, 5)) {
const date = new Date(entry.timestamp).toLocaleString();
console.log(
` ${chalk.dim(date)} - ${entry.model}: $${entry.cost_usd.toFixed(4)}`
);
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(chalk.red(`❌ Failed to fetch billing info: ${errorMessage}`));
console.log(chalk.dim('Your API key may be invalid. Try `dexto login` to refresh.'));
}
}

View File

@@ -0,0 +1,780 @@
import fs from 'fs-extra';
import path from 'path';
import chalk from 'chalk';
import * as p from '@clack/prompts';
import { logger } from '@dexto/core';
import { selectOrExit, textOrExit } from '../utils/prompt-helpers.js';
import {
promptForProjectName,
createProjectDirectory,
setupGitRepo,
createGitignore,
initPackageJson,
createTsconfigForApp,
installDependencies,
createEnvExample,
ensureDirectory,
} from '../utils/scaffolding-utils.js';
import {
generateIndexForImage,
generateWebServerIndex,
generateWebAppHTML,
generateWebAppJS,
generateWebAppCSS,
generateAppReadme,
generateExampleTool,
generateDiscoveryScript,
} from '../utils/template-engine.js';
import { getExecutionContext } from '@dexto/agent-management';
type AppMode = 'from-image' | 'from-core';
type AppType = 'script' | 'webapp';
export interface CreateAppOptions {
fromImage?: string;
fromCore?: boolean;
type?: AppType;
}
/**
* Creates a Dexto application with two possible modes:
* - from-image: Use existing image (recommended)
* - from-core: Build from @dexto/core with custom providers (advanced)
*
* Note: To create a new image that extends another image, use `dexto create-image` instead.
*
* @param name - Optional name of the app project
* @param options - Optional flags to specify mode and base image
* @returns The absolute path to the created project directory
*/
export async function createDextoProject(
name?: string,
options?: CreateAppOptions
): Promise<string> {
console.log(chalk.blue('🚀 Creating a Dexto application\n'));
// Step 1: Get project name
const projectName = name
? name
: await promptForProjectName('my-dexto-app', 'What do you want to name your app?');
// Step 2: Determine app type
let appType: AppType = options?.type || 'script';
if (!options?.type) {
appType = await selectOrExit<AppType>(
{
message: 'What type of app?',
options: [
{
value: 'script',
label: 'Script',
hint: 'Simple script (default)',
},
{
value: 'webapp',
label: 'Web App',
hint: 'REST API server with web frontend',
},
],
},
'App creation cancelled'
);
}
// Step 3: Determine app mode (from flags or prompt)
let mode: AppMode;
let baseImage: string | undefined;
if (options?.fromCore) {
mode = 'from-core';
} else if (options?.fromImage) {
mode = 'from-image';
baseImage = options.fromImage;
} else {
// No flags provided, use interactive prompt
mode = await selectOrExit<AppMode>(
{
message: 'How do you want to start?',
options: [
{
value: 'from-image',
label: 'Use existing image (recommended)',
hint: 'Pre-built image with providers',
},
{
value: 'from-core',
label: 'Build from core (advanced)',
hint: 'Custom standalone app with your own providers',
},
],
},
'App creation cancelled'
);
}
const spinner = p.spinner();
let projectPath: string;
const originalCwd = process.cwd();
try {
// Create project directory
projectPath = await createProjectDirectory(projectName, spinner);
// Change to project directory
process.chdir(projectPath);
if (mode === 'from-core') {
// Mode C: Build from core - custom image with bundler
await scaffoldFromCore(projectPath, projectName, spinner);
spinner.stop(chalk.green(`✓ Successfully created app: ${projectName}`));
console.log(`\n${chalk.cyan('Next steps:')}`);
console.log(` ${chalk.gray('$')} cd ${projectName}`);
console.log(
` ${chalk.gray('$')} pnpm start ${chalk.gray('(discovers providers, builds, and runs)')}`
);
console.log(`\n${chalk.gray('Learn more:')} https://docs.dexto.ai\n`);
return projectPath;
}
// For from-image mode, select the image (if not already provided via flag)
if (!baseImage) {
const imageChoice = await selectOrExit<string>(
{
message: 'Which image?',
options: [
{
value: '@dexto/image-local',
label: '@dexto/image-local (recommended)',
hint: 'Local dev - SQLite, filesystem',
},
{ value: 'custom', label: 'Custom npm package...' },
],
},
'App creation cancelled'
);
if (imageChoice === 'custom') {
const customImage = await textOrExit(
{
message: 'Enter the npm package name:',
placeholder: '@myorg/image-custom',
validate: (value) => {
if (!value || value.trim() === '') {
return 'Package name is required';
}
return undefined;
},
},
'App creation cancelled'
);
baseImage = customImage;
} else {
baseImage = imageChoice;
}
}
// Scaffold from existing image
await scaffoldFromImage(projectPath, projectName, baseImage, appType, originalCwd, spinner);
spinner.stop(chalk.green(`✓ Successfully created app: ${projectName}`));
console.log(`\n${chalk.cyan('Next steps:')}`);
console.log(` ${chalk.gray('$')} cd ${projectName}`);
console.log(` ${chalk.gray('$')} pnpm start`);
console.log(`\n${chalk.gray('Learn more:')} https://docs.dexto.ai\n`);
return projectPath;
} catch (error) {
// Restore original directory on error
if (originalCwd) {
try {
process.chdir(originalCwd);
} catch {
// Ignore if we can't restore - likely a more serious issue
}
}
if (spinner) {
spinner.stop(chalk.red('✗ Failed to create app'));
}
throw error;
}
}
/**
* Mode A: Scaffold app using existing image
*/
async function scaffoldFromImage(
projectPath: string,
projectName: string,
imageName: string,
appType: AppType,
originalCwd: string,
spinner: ReturnType<typeof p.spinner>
): Promise<void> {
spinner.start('Setting up app structure...');
// Resolve package name for local images (needed for import statements)
let packageNameForImport = imageName;
if (imageName.startsWith('.')) {
const fullPath = path.resolve(originalCwd, imageName);
let packageDir = fullPath;
// If path ends with /dist/index.js, resolve to package root (parent of dist)
if (fullPath.endsWith('/dist/index.js') || fullPath.endsWith('\\dist\\index.js')) {
packageDir = path.dirname(path.dirname(fullPath));
} else if (fullPath.endsWith('.js')) {
packageDir = path.dirname(fullPath);
}
// Read package.json to get the actual package name for imports
try {
const pkgJsonPath = path.join(packageDir, 'package.json');
const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'));
packageNameForImport = pkgJson.name;
} catch (_error) {
logger.warn(`Could not read package.json from ${packageDir}, using path as import`);
}
}
// Create folders
await ensureDirectory('src');
await ensureDirectory('agents');
// Create src/index.ts based on app type
let indexContent: string;
if (appType === 'webapp') {
indexContent = generateWebServerIndex({
projectName,
packageName: projectName,
description: 'Dexto web server application',
imageName: packageNameForImport,
});
// Create web app directory and files
await ensureDirectory('app');
await ensureDirectory('app/assets');
await fs.writeFile('app/index.html', generateWebAppHTML(projectName));
await fs.writeFile('app/assets/main.js', generateWebAppJS());
await fs.writeFile('app/assets/style.css', generateWebAppCSS());
} else {
indexContent = generateIndexForImage({
projectName,
packageName: projectName,
description: 'Dexto application',
imageName: packageNameForImport,
});
}
await fs.writeFile('src/index.ts', indexContent);
// Create default agent config
const agentConfig = `# Default Agent Configuration
# Image: Specifies the provider bundle for this agent
image: '${imageName}'
# System prompt
systemPrompt:
contributors:
- id: primary
type: static
priority: 0
content: |
You are a helpful AI assistant.
# LLM configuration
llm:
provider: openai
model: gpt-4o
apiKey: $OPENAI_API_KEY
# Storage
storage:
cache:
type: in-memory
database:
type: sqlite
path: ./data/agent.db
blob:
type: local
storePath: ./data/blobs
# Custom tools - uncomment to enable filesystem and process tools
# customTools:
# - type: filesystem-tools
# allowedPaths: ['.']
# blockedPaths: ['.git', 'node_modules']
# - type: process-tools
# securityLevel: moderate
# MCP servers - add external tools here
# mcpServers:
# filesystem:
# type: stdio
# command: npx
# args:
# - -y
# - "@modelcontextprotocol/server-filesystem"
# - .
`;
await fs.writeFile('agents/default.yml', agentConfig);
spinner.message('Creating configuration files...');
// Create package.json
await initPackageJson(projectPath, projectName, 'app');
// Add scripts
const packageJsonPath = path.join(projectPath, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
packageJson.scripts = {
start: 'tsx src/index.ts',
build: 'tsc',
...packageJson.scripts,
};
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
// Create tsconfig.json
await createTsconfigForApp(projectPath, 'src');
// Create README
const readmeContent = generateAppReadme({
projectName,
packageName: projectName,
description: 'Dexto application using official image',
imageName,
});
await fs.writeFile('README.md', readmeContent);
// Create .env.example
await createEnvExample(projectPath, {
OPENAI_API_KEY: 'sk-...',
ANTHROPIC_API_KEY: 'sk-ant-...',
});
// Create .gitignore
await createGitignore(projectPath);
// Initialize git
spinner.message('Initializing git repository...');
await setupGitRepo(projectPath);
spinner.message('Installing dependencies...');
// Detect if we're in dexto source - use workspace protocol for local development
const executionContext = getExecutionContext();
const isDextoSource = executionContext === 'dexto-source';
const agentMgmtVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
// Resolve relative paths to absolute for local images
// (npm/pnpm need absolute paths to package directories when installing from file system)
let resolvedImageName = imageName;
if (imageName.startsWith('.')) {
const fullPath = path.resolve(originalCwd, imageName);
// If path ends with /dist/index.js, resolve to package root (parent of dist)
if (fullPath.endsWith('/dist/index.js') || fullPath.endsWith('\\dist\\index.js')) {
resolvedImageName = path.dirname(path.dirname(fullPath));
} else if (fullPath.endsWith('.js')) {
// If it's a .js file but not the standard structure, use the directory
resolvedImageName = path.dirname(fullPath);
} else {
// It's already a directory
resolvedImageName = fullPath;
}
} else if (isDextoSource && imageName.startsWith('@dexto/image-')) {
// In dexto source, resolve official images to local workspace packages
// e.g., @dexto/image-local -> packages/image-local
const imagePkgName = imageName.replace('@dexto/', '');
const imagePkgPath = path.resolve(originalCwd, 'packages', imagePkgName);
if (await fs.pathExists(imagePkgPath)) {
resolvedImageName = imagePkgPath;
}
}
// Install dependencies (use pnpm in dexto source for workspace protocol support)
// Image is loaded as "environment" - we import from core packages directly
const coreVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
const serverVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
const dependencies = [
resolvedImageName, // Image provides the environment/providers
`@dexto/core@${coreVersion}`, // Import DextoAgent from here
`@dexto/agent-management@${agentMgmtVersion}`, // Import loadAgentConfig from here
'tsx',
];
// Add @dexto/server dependency for webapp type
if (appType === 'webapp') {
dependencies.push(`@dexto/server@${serverVersion}`);
}
await installDependencies(
projectPath,
{
dependencies,
devDependencies: ['typescript@^5.0.0', '@types/node@^20.0.0'],
},
isDextoSource ? 'pnpm' : undefined
);
}
/**
* Mode B: Scaffold standalone app built from @dexto/core
* Supports both dev (runtime discovery) and production (build-time discovery) workflows
*/
async function scaffoldFromCore(
projectPath: string,
projectName: string,
spinner: ReturnType<typeof p.spinner>
): Promise<void> {
spinner.start('Setting up app structure...');
// Always include example tool for from-core mode
const includeExample = true;
// Create convention-based folders
await ensureDirectory('src');
await ensureDirectory('scripts');
await ensureDirectory('tools');
await ensureDirectory('blob-store');
await ensureDirectory('compression');
await ensureDirectory('plugins');
await ensureDirectory('agents');
// Create .gitkeep files for empty directories
await fs.writeFile('blob-store/.gitkeep', '');
await fs.writeFile('compression/.gitkeep', '');
await fs.writeFile('plugins/.gitkeep', '');
// Create example tool if requested
if (includeExample) {
await ensureDirectory('tools/example-tool');
const exampleToolCode = generateExampleTool('example-tool');
await fs.writeFile('tools/example-tool/index.ts', exampleToolCode);
} else {
await fs.writeFile('tools/.gitkeep', '');
}
spinner.message('Generating app files...');
// Create dexto.config.ts for provider discovery configuration
const dextoConfigContent = `import { defineConfig } from '@dexto/core';
/**
* Dexto Configuration
*
* Provider Discovery Modes:
* - Development (pnpm dev): Runtime discovery - fast iteration, no rebuild needed
* - Production (pnpm build): Build-time discovery - validates and optimizes everything
*
* This mirrors Next.js approach:
* - next dev: Runtime compilation
* - next build + next start: Pre-built production bundle
*/
export default defineConfig({
providers: {
// Auto-discover providers from convention-based folders
autoDiscover: true,
folders: ['tools', 'blob-store', 'compression', 'plugins'],
},
});
`;
await fs.writeFile('dexto.config.ts', dextoConfigContent);
// Create build-time discovery script
const discoveryScript = generateDiscoveryScript();
await fs.writeFile('scripts/discover-providers.ts', discoveryScript);
// Create app entry point - completely clean, no provider registration code
const appIndexContent = `// Standalone Dexto app
// Development: Providers auto-discovered at runtime (pnpm dev)
// Production: Providers bundled at build time (pnpm build + pnpm start)
import { DextoAgent } from '@dexto/core';
import { loadAgentConfig } from '@dexto/agent-management';
async function main() {
console.log('🚀 Starting ${projectName}\\n');
// Load agent configuration
// In dev mode: providers discovered at runtime from dexto.config.ts
// In production: providers pre-registered at build time
const config = await loadAgentConfig('./agents/default.yml');
// Create agent
const agent = new DextoAgent(config, './agents/default.yml');
await agent.start();
console.log('✅ Agent started\\n');
// Create a session
const session = await agent.createSession();
// Example interaction
const response = await agent.run(
'Hello! Can you help me understand what custom tools are available?',
undefined, // imageDataInput
undefined, // fileDataInput
session.id // sessionId
);
console.log('Agent response:', response);
// Cleanup
await agent.stop();
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
`;
await fs.writeFile('src/index.ts', appIndexContent);
// Create default agent config
const agentConfig = `# Default Agent Configuration
# System prompt
systemPrompt:
contributors:
- id: primary
type: static
priority: 0
content: |
You are a helpful AI assistant with custom tools.
# LLM configuration
llm:
provider: openai
model: gpt-4o
apiKey: $OPENAI_API_KEY
# Storage
storage:
cache:
type: in-memory
database:
type: sqlite
path: ./data/agent.db
blob:
type: local
storePath: ./data/blobs
# Custom tools are auto-discovered at runtime from tools/ folder
# See dexto.config.ts for provider discovery configuration
# MCP servers - add external tools here
# mcpServers:
# filesystem:
# type: stdio
# command: npx
# args:
# - -y
# - "@modelcontextprotocol/server-filesystem"
# - .
`;
await fs.writeFile('agents/default.yml', agentConfig);
spinner.message('Creating configuration files...');
// Create package.json for standalone app
await initPackageJson(projectPath, projectName, 'app');
// Add scripts for both development and production workflows
const packageJsonPath = path.join(projectPath, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
packageJson.scripts = {
// Development: runtime discovery (fast iteration)
dev: 'tsx src/index.ts',
// Production: build-time discovery + bundling
build: 'tsx scripts/discover-providers.ts && tsup',
start: 'node dist/_entry.js',
typecheck: 'tsc --noEmit',
discover: 'tsx scripts/discover-providers.ts',
...packageJson.scripts,
};
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
// Create tsconfig.json
const tsconfigContent = {
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
lib: ['ES2022'],
outDir: './dist',
rootDir: './src',
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
resolveJsonModule: true,
declaration: true,
declarationMap: true,
sourceMap: true,
},
include: ['src/**/*', 'tools/**/*', 'blob-store/**/*', 'compression/**/*', 'plugins/**/*'],
exclude: ['node_modules', 'dist'],
};
await fs.writeFile('tsconfig.json', JSON.stringify(tsconfigContent, null, 2));
// Create tsup.config.ts - builds from generated _entry.ts for production
const tsupConfig = `import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/_entry.ts'], // Generated by scripts/discover-providers.ts
format: ['esm'],
dts: false, // Skip DTS for build artifacts
sourcemap: true,
clean: true,
external: ['@dexto/core', '@dexto/agent-management'],
noExternal: [],
});
`;
await fs.writeFile('tsup.config.ts', tsupConfig);
// Create .gitignore - ignore generated build artifacts
await createGitignore(projectPath, [
'*.tsbuildinfo',
'dist/',
'src/_entry.ts',
'src/_providers.ts',
]);
// Create .env.example
await createEnvExample(projectPath, {
OPENAI_API_KEY: 'sk-...',
ANTHROPIC_API_KEY: 'sk-ant-...',
});
// Create README
const readmeContent = `# ${projectName}
Standalone Dexto app with convention-based auto-discovery.
## Getting Started
\`\`\`bash
# Install dependencies
pnpm install
# Add your API key
cp .env.example .env
# Edit .env and add your OPENAI_API_KEY
# Development (runtime discovery - fast iteration)
pnpm dev
# Production (build-time discovery + optimized bundle)
pnpm build
pnpm start
\`\`\`
That's it! Custom providers are discovered automatically:
- **Dev mode** (\`pnpm dev\`): Runtime discovery - add/modify providers without rebuilding
- **Production** (\`pnpm build\`): Build-time discovery - validates and bundles everything
## Project Structure
\`\`\`
${projectName}/
├── src/
│ ├── index.ts # Your app code (clean, no boilerplate!)
│ ├── _entry.ts # Auto-generated (build only, gitignored)
│ └── _providers.ts # Auto-generated (build only, gitignored)
├── scripts/
│ └── discover-providers.ts # Build-time discovery script
├── dexto.config.ts # Provider discovery configuration
├── tools/ # Add custom tool providers here
├── blob-store/ # Add custom blob storage providers here
├── compression/ # Add custom compression providers here
├── plugins/ # Add custom plugins here
└── agents/
└── default.yml # Agent configuration
\`\`\`
## Adding Custom Providers
1. Create a provider in the appropriate folder (tools/, blob-store/, compression/, plugins/)
2. Export it with the naming convention: \`<folderName>Provider\`
3. Run \`pnpm dev\` (instant) or \`pnpm build\` (validated) - everything is auto-discovered!
**Example** - Adding a custom tool:
\`\`\`typescript
// tools/my-tool/index.ts
import { z } from 'zod';
export const myToolProvider = {
type: 'my-tool',
configSchema: z.object({ type: z.literal('my-tool') }),
tools: [
{
name: 'do_something',
description: 'Does something useful',
parameters: z.object({ input: z.string() }),
execute: async ({ input }) => {
return \`Processed: \${input}\`;
},
},
],
};
\`\`\`
That's it! No imports, no registration code needed.
## Scripts
- \`pnpm start\` - Build and run (auto-discovers providers)
- \`pnpm run dev\` - Development mode with hot reload
- \`pnpm run build\` - Build only
- \`pnpm run discover\` - Manually run provider discovery
- \`pnpm run typecheck\` - Type check
## How It Works
1. **Discovery**: Scans conventional folders for providers
2. **Generation**: Creates \`src/_providers.ts\` (registrations) and \`src/_entry.ts\` (wiring)
3. **Build**: Bundles everything into \`dist/_entry.js\`
4. **Run**: Your clean app code runs with all providers pre-registered
## Learn More
- [Dexto Documentation](https://docs.dexto.ai)
- [Custom Tools Guide](https://docs.dexto.ai/docs/guides/custom-tools)
`;
await fs.writeFile('README.md', readmeContent);
// Initialize git
spinner.message('Initializing git repository...');
await setupGitRepo(projectPath);
spinner.message('Installing dependencies...');
// Detect if we're in dexto source - use workspace protocol for local development
const executionContext = getExecutionContext();
const isDextoSource = executionContext === 'dexto-source';
const coreVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
const agentMgmtVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
// Install dependencies (use pnpm in dexto source for workspace protocol support)
await installDependencies(
projectPath,
{
dependencies: [
`@dexto/core@${coreVersion}`,
'zod',
`@dexto/agent-management@${agentMgmtVersion}`,
],
devDependencies: ['typescript@^5.0.0', '@types/node@^20.0.0', 'tsx', 'tsup'],
},
isDextoSource ? 'pnpm' : undefined
);
}

View File

@@ -0,0 +1,270 @@
import path from 'path';
import * as p from '@clack/prompts';
import chalk from 'chalk';
import { selectOrExit, textOrExit, confirmOrExit } from '../utils/prompt-helpers.js';
import {
promptForProjectName,
createProjectDirectory,
setupGitRepo,
createGitignore,
initPackageJson,
createTsconfigForImage,
installDependencies,
ensureDirectory,
} from '../utils/scaffolding-utils.js';
import {
generateDextoImageFile,
generateImageReadme,
generateExampleTool,
} from '../utils/template-engine.js';
import fs from 'fs-extra';
import { getExecutionContext } from '@dexto/agent-management';
/**
* Creates a Dexto image project - a distributable agent harness package
* @param name - Optional name of the image project
* @returns The absolute path to the created project directory
*/
export async function createImage(name?: string): Promise<string> {
console.log(chalk.blue('🎨 Creating a Dexto image - a distributable agent harness package\n'));
// Step 1: Get project name
const projectName = name
? name
: await promptForProjectName('my-dexto-image', 'What do you want to name your image?');
// Step 2: Get description
const description = await textOrExit(
{
message: 'Describe your image:',
placeholder: 'Custom agent harness for my organization',
defaultValue: 'Custom agent harness for my organization',
},
'Image creation cancelled'
);
// Step 3: Starting point - new base or extend existing
const startingPoint = await selectOrExit<'base' | 'extend'>(
{
message: 'Starting point:',
options: [
{ value: 'base', label: 'New base image (build from scratch)' },
{ value: 'extend', label: 'Extend existing image (add providers to base)' },
],
},
'Image creation cancelled'
);
let baseImage: string | undefined;
if (startingPoint === 'extend') {
// Step 4: Which image to extend?
const baseImageChoice = await selectOrExit<string>(
{
message: 'Which image to extend?',
options: [
{
value: '@dexto/image-local',
label: '@dexto/image-local (local development)',
},
{ value: '@dexto/image-cloud', label: '@dexto/image-cloud (cloud production)' },
{ value: '@dexto/image-edge', label: '@dexto/image-edge (edge/serverless)' },
{ value: 'custom', label: 'Custom npm package...' },
],
},
'Image creation cancelled'
);
if (baseImageChoice === 'custom') {
const customBase = await textOrExit(
{
message: 'Enter the npm package name:',
placeholder: '@myorg/image-base',
validate: (value) => {
if (!value || value.trim() === '') {
return 'Package name is required';
}
return undefined;
},
},
'Image creation cancelled'
);
baseImage = customBase;
} else {
baseImage = baseImageChoice;
}
}
// Step 5: Target environment
const target = await selectOrExit<string>(
{
message: 'Target environment:',
options: [
{ value: 'local-development', label: 'Local development' },
{ value: 'cloud-production', label: 'Cloud production' },
{ value: 'edge-serverless', label: 'Edge/serverless' },
{ value: 'custom', label: 'Custom' },
],
},
'Image creation cancelled'
);
// Step 6: Include example providers?
const includeExamples = await confirmOrExit(
{
message: 'Include example tool provider?',
initialValue: true,
},
'Image creation cancelled'
);
// Start scaffolding
const spinner = p.spinner();
let projectPath: string | undefined;
try {
// Save original cwd before changing directories (for resolving relative paths)
const originalCwd = process.cwd();
// Create project directory
projectPath = await createProjectDirectory(projectName, spinner);
// Change to project directory
process.chdir(projectPath);
spinner.start('Setting up project structure...');
// Create convention-based folders
await ensureDirectory('tools');
await ensureDirectory('blob-store');
await ensureDirectory('compression');
await ensureDirectory('plugins');
// Create .gitkeep files for empty directories
await fs.writeFile('blob-store/.gitkeep', '');
await fs.writeFile('compression/.gitkeep', '');
await fs.writeFile('plugins/.gitkeep', '');
// Create example tool if requested
if (includeExamples) {
await ensureDirectory('tools/example-tool');
const exampleToolCode = generateExampleTool('example-tool');
await fs.writeFile('tools/example-tool/index.ts', exampleToolCode);
} else {
await fs.writeFile('tools/.gitkeep', '');
}
spinner.message('Generating configuration files...');
// Create dexto.image.ts
const dextoImageContent = generateDextoImageFile({
projectName,
packageName: projectName,
description,
imageName: projectName,
...(baseImage ? { baseImage } : {}),
target,
});
await fs.writeFile('dexto.image.ts', dextoImageContent);
// Create package.json
await initPackageJson(projectPath, projectName, 'image');
// Update package.json with build script
const packageJsonPath = path.join(projectPath, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
packageJson.scripts = {
build: 'dexto-bundle build',
typecheck: 'tsc --noEmit',
...packageJson.scripts,
};
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
// Create tsconfig.json
await createTsconfigForImage(projectPath);
// Create README
const readmeContent = generateImageReadme({
projectName,
packageName: projectName,
description,
imageName: projectName,
...(baseImage ? { baseImage } : {}),
});
await fs.writeFile('README.md', readmeContent);
// Create .gitignore
await createGitignore(projectPath, ['*.tsbuildinfo']);
// Initialize git
spinner.message('Initializing git repository...');
await setupGitRepo(projectPath);
spinner.message('Installing dependencies...');
// Detect if we're in dexto source - use workspace protocol for local development
const executionContext = getExecutionContext();
const isDextoSource = executionContext === 'dexto-source';
const coreVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
const bundlerVersion = isDextoSource ? 'workspace:*' : '^1.3.0';
// Determine dependencies based on whether extending
const dependencies: string[] = [`@dexto/core@${coreVersion}`, 'zod'];
const devDependencies = [
'typescript@^5.0.0',
'@types/node@^20.0.0',
`@dexto/image-bundler@${bundlerVersion}`,
];
if (baseImage) {
// Resolve base image path if we're in dexto source
let resolvedBaseImage = baseImage;
if (isDextoSource && baseImage.startsWith('@dexto/image-')) {
// In dexto source, resolve official images to local workspace packages
// e.g., @dexto/image-local -> packages/image-local
const imagePkgName = baseImage.replace('@dexto/', '');
const imagePkgPath = path.resolve(originalCwd, 'packages', imagePkgName);
if (await fs.pathExists(imagePkgPath)) {
resolvedBaseImage = imagePkgPath;
}
}
dependencies.push(resolvedBaseImage);
}
// Install dependencies (use pnpm in dexto source for workspace protocol support)
await installDependencies(
projectPath,
{
dependencies,
devDependencies,
},
isDextoSource ? 'pnpm' : undefined
);
spinner.stop(chalk.green(`✓ Successfully created image: ${projectName}`));
console.log(`\n${chalk.cyan('Next steps:')}`);
console.log(` ${chalk.gray('$')} cd ${projectName}`);
console.log(` ${chalk.gray('$')} pnpm run build`);
console.log(
`\n${chalk.gray('Add your custom providers to the convention-based folders:')}`
);
console.log(` ${chalk.gray('tools/')} - Custom tool providers`);
console.log(` ${chalk.gray('blob-store/')} - Blob storage providers`);
console.log(` ${chalk.gray('compression/')} - Compression strategies`);
console.log(` ${chalk.gray('plugins/')} - Plugin providers`);
console.log(`\n${chalk.gray('Learn more:')} https://docs.dexto.ai/docs/guides/images\n`);
} catch (error) {
if (spinner) {
spinner.stop(chalk.red('✗ Failed to create image'));
}
throw error;
}
if (!projectPath) {
throw new Error('Failed to create project directory');
}
return projectPath;
}

View File

@@ -0,0 +1,107 @@
/**
* Session Formatting Utilities
*
* This module contains formatting functions for session-related CLI output.
* Shared between interactive and non-interactive session commands.
*/
import chalk from 'chalk';
import type { SessionMetadata, InternalMessage, ToolCall } from '@dexto/core';
import { isAssistantMessage } from '@dexto/core';
/**
* Helper to format session information consistently
*/
export function formatSessionInfo(
sessionId: string,
metadata?: SessionMetadata,
isCurrent: boolean = false
): string {
const prefix = isCurrent ? chalk.green('→') : ' ';
const name = isCurrent ? chalk.green.bold(sessionId) : chalk.cyan(sessionId);
let info = `${prefix} ${name}`;
if (metadata) {
const messages = metadata.messageCount || 0;
const activity =
metadata.lastActivity && metadata.lastActivity > 0
? new Date(metadata.lastActivity).toLocaleString()
: 'Never';
info += chalk.gray(` (${messages} messages, last: ${activity})`);
if (isCurrent) {
info += chalk.rgb(255, 165, 0)(' [ACTIVE]');
}
}
return info;
}
/**
* Helper to format conversation history
*/
export function formatHistoryMessage(message: InternalMessage, index: number): string {
const timestamp = message.timestamp
? new Date(message.timestamp).toLocaleTimeString()
: `#${index + 1}`;
let roleColor = chalk.gray;
let displayLabel: string = message.role;
switch (message.role) {
case 'user':
roleColor = chalk.blue;
displayLabel = 'You';
break;
case 'assistant':
roleColor = chalk.green;
displayLabel = 'Assistant';
break;
case 'system':
roleColor = chalk.rgb(255, 165, 0);
displayLabel = 'System';
break;
case 'tool':
roleColor = chalk.green;
displayLabel = 'Tool';
break;
}
// Handle content formatting
let content = '';
if (typeof message.content === 'string') {
content = message.content;
} else if (message.content === null) {
content = '[No content]';
} else if (Array.isArray(message.content)) {
// Handle multimodal content
content = message.content
.map((part) => {
if (part.type === 'text') return part.text;
if (part.type === 'image') return '[Image]';
if (part.type === 'file') return `[File: ${part.filename || 'unknown'}]`;
return '[Unknown content]';
})
.join(' ');
} else {
content = '[No content]';
}
// Truncate very long messages
if (content.length > 200) {
content = content.substring(0, 200) + '...';
}
// Format tool calls if present
let toolInfo = '';
if (isAssistantMessage(message) && message.toolCalls && message.toolCalls.length > 0) {
const toolNames = message.toolCalls
.map((tc: ToolCall) => tc.function?.name || 'unknown')
.join(', ');
toolInfo = chalk.gray(` [Tools: ${toolNames}]`);
}
return ` ${chalk.gray(timestamp)} ${roleColor.bold(displayLabel)}: ${content}${toolInfo}`;
}

View File

@@ -0,0 +1,60 @@
// packages/cli/src/cli/commands/index.ts
// Project setup commands
export { createDextoProject, type CreateAppOptions } from './create-app.js';
export { createImage } from './create-image.js';
export { getUserInputToInitDextoApp, initDexto, postInitDexto } from './init-app.js';
export { handleSetupCommand, type CLISetupOptions, type CLISetupOptionsInput } from './setup.js';
export { handleInstallCommand, type InstallCommandOptions } from './install.js';
export { handleUninstallCommand, type UninstallCommandOptions } from './uninstall.js';
export {
handleListAgentsCommand,
type ListAgentsCommandOptions,
type ListAgentsCommandOptionsInput,
} from './list-agents.js';
export { handleWhichCommand, type WhichCommandOptions } from './which.js';
export {
handleSyncAgentsCommand,
shouldPromptForSync,
markSyncDismissed,
clearSyncDismissed,
type SyncAgentsCommandOptions,
} from './sync-agents.js';
// Auth commands
export { handleLoginCommand, handleLogoutCommand, handleStatusCommand } from './auth/index.js';
// Billing commands
export { handleBillingStatusCommand } from './billing/index.js';
// Plugin commands
export {
handlePluginListCommand,
handlePluginInstallCommand,
handlePluginUninstallCommand,
handlePluginValidateCommand,
// Marketplace handlers
handleMarketplaceAddCommand,
handleMarketplaceRemoveCommand,
handleMarketplaceUpdateCommand,
handleMarketplaceListCommand,
handleMarketplacePluginsCommand,
handleMarketplaceInstallCommand,
type PluginListCommandOptions,
type PluginListCommandOptionsInput,
type PluginInstallCommandOptions,
type PluginInstallCommandOptionsInput,
type PluginUninstallCommandOptions,
type PluginUninstallCommandOptionsInput,
type PluginValidateCommandOptions,
type PluginValidateCommandOptionsInput,
// Marketplace types
type MarketplaceAddCommandOptionsInput,
type MarketplaceRemoveCommandOptionsInput,
type MarketplaceUpdateCommandOptionsInput,
type MarketplaceListCommandOptionsInput,
type MarketplaceInstallCommandOptionsInput,
} from './plugin.js';

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { createDextoDirectories, createDextoExampleFile, postInitDexto } from './init-app.js';
describe('Init Module', () => {
let tempDir: string;
beforeEach(async () => {
// Create a temporary directory for testing
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-init-test-'));
});
afterEach(async () => {
// Cleanup the temporary directory
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('createDextoDirectories', () => {
it('creates dexto and agents directories when they do not exist', async () => {
const result = await createDextoDirectories(tempDir);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.dirPath).toBe(path.join(tempDir, 'dexto'));
}
// Verify directories exist
const dextoDir = path.join(tempDir, 'dexto');
const agentsDir = path.join(tempDir, 'dexto', 'agents');
expect(
await fs
.access(dextoDir)
.then(() => true)
.catch(() => false)
).toBe(true);
expect(
await fs
.access(agentsDir)
.then(() => true)
.catch(() => false)
).toBe(true);
});
it('returns false when dexto directory already exists', async () => {
// Create the dexto directory first
const dextoDir = path.join(tempDir, 'dexto');
await fs.mkdir(dextoDir, { recursive: true });
const result = await createDextoDirectories(tempDir);
expect(result.ok).toBe(false);
});
});
describe('createDextoExampleFile', () => {
it('creates example file with correct content', async () => {
// Change to temp directory to simulate real usage where paths are relative to cwd
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
const dextoDir = path.join('src', 'dexto'); // Relative path like real usage
await fs.mkdir(dextoDir, { recursive: true });
const examplePath = await createDextoExampleFile(dextoDir);
expect(examplePath).toBe(path.join(dextoDir, 'dexto-example.ts'));
// Verify file exists
expect(
await fs
.access(examplePath)
.then(() => true)
.catch(() => false)
).toBe(true);
// Verify content contains expected elements
const content = await fs.readFile(examplePath, 'utf8');
expect(content).toContain(
"import { DextoAgent, loadAgentConfig } from '@dexto/core'"
);
expect(content).toContain("console.log('🚀 Starting Dexto Basic Example");
expect(content).toContain('./src/dexto/agents/coding-agent.yml'); // Correct relative path
expect(content).toContain('const agent = new DextoAgent(config)');
} finally {
process.chdir(originalCwd);
}
});
it('generates correct config path for different directory structures', async () => {
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
const dextoDir = path.join('custom', 'dexto'); // Relative path
await fs.mkdir(dextoDir, { recursive: true });
const examplePath = await createDextoExampleFile(dextoDir);
const content = await fs.readFile(examplePath, 'utf8');
expect(content).toContain('./custom/dexto/agents/coding-agent.yml');
} finally {
process.chdir(originalCwd);
}
});
});
describe('postInitDexto', () => {
it('runs without throwing errors', async () => {
// This function just prints output, so we mainly test it doesn't crash
expect(() => postInitDexto('src')).not.toThrow();
});
});
});

View File

@@ -0,0 +1,401 @@
import * as p from '@clack/prompts';
import chalk from 'chalk';
import fs from 'node:fs/promises';
import fsExtra from 'fs-extra';
import path from 'node:path';
import { getPackageManager, getPackageManagerInstallCommand } from '../utils/package-mgmt.js';
import { executeWithTimeout } from '../utils/execute.js';
import { createRequire } from 'module';
import { type LLMProvider, logger } from '@dexto/core';
import { updateDextoConfigFile } from '../utils/project-utils.js';
import { saveProviderApiKey } from '@dexto/agent-management';
import {
getProviderDisplayName,
isValidApiKeyFormat,
PROVIDER_OPTIONS,
} from '../utils/provider-setup.js';
const require = createRequire(import.meta.url);
/**
* Get user preferences needed to initialize a Dexto app
* @returns The user preferences
*/
export async function getUserInputToInitDextoApp(): Promise<{
llmProvider: LLMProvider;
llmApiKey: string;
directory: string;
createExampleFile: boolean;
}> {
const answers = await p.group(
{
llmProvider: () =>
p.select({
message: 'Choose your AI provider',
options: PROVIDER_OPTIONS,
}),
llmApiKey: async ({ results }) => {
const llmProvider = results.llmProvider as LLMProvider;
const selection = await p.select({
message: `Enter your API key for ${getProviderDisplayName(llmProvider)}?`,
options: [
{ value: 'enter', label: 'Enter', hint: 'recommended' },
{ value: 'skip', label: 'Skip', hint: '' },
],
initialValue: 'enter',
});
if (p.isCancel(selection)) {
p.cancel('Dexto initialization cancelled');
process.exit(0);
}
if (selection === 'enter') {
const apiKey = await p.password({
message: `Enter your ${getProviderDisplayName(llmProvider)} API key`,
mask: '*',
validate: (value) => {
if (!value || value.trim().length === 0) {
return 'API key is required';
}
if (!isValidApiKeyFormat(value.trim(), llmProvider)) {
return `Invalid ${getProviderDisplayName(llmProvider)} API key format`;
}
return undefined;
},
});
if (p.isCancel(apiKey)) {
p.cancel('Dexto initialization cancelled');
process.exit(0);
}
return apiKey;
}
return '';
},
directory: () =>
p.text({
message: 'Enter the directory to add the dexto files in',
placeholder: 'src/',
defaultValue: 'src/',
}),
createExampleFile: () =>
p.confirm({
message: 'Create a dexto example file? [Recommended]',
initialValue: true,
}),
},
{
onCancel: () => {
p.cancel('Dexto initialization cancelled');
process.exit(0);
},
}
);
// Type assertion to bypass the possible 'Symbol' type returned by p.group which is handled in onCancel
return answers as {
llmProvider: LLMProvider;
directory: string;
llmApiKey: string;
createExampleFile: boolean;
};
}
/**
* Initializes an existing project with Dexto in the given directory.
* @param directory - The directory to initialize the Dexto project in
* @param llmProvider - The LLM provider to use
* @param llmApiKey - The API key for the LLM provider
* @returns The path to the created Dexto project
*/
export async function initDexto(
directory: string,
createExampleFile = true,
llmProvider?: LLMProvider,
llmApiKey?: string
): Promise<void> {
const spinner = p.spinner();
try {
// install dexto
const packageManager = getPackageManager();
const installCommand = getPackageManagerInstallCommand(packageManager);
spinner.start('Installing Dexto...');
const label = 'latest';
logger.debug(
`Installing Dexto using ${packageManager} with install command: ${installCommand} and label: ${label}`
);
try {
await executeWithTimeout(packageManager, [installCommand, `@dexto/core@${label}`], {
cwd: process.cwd(),
});
} catch (installError) {
// Handle pnpm workspace root add error specifically
console.error(
`Install error: ${
installError instanceof Error ? installError.message : String(installError)
}`
);
if (
packageManager === 'pnpm' &&
installError instanceof Error &&
/\bERR_PNPM_ADDING_TO_ROOT\b/.test(installError.message)
) {
spinner.stop(chalk.red('Error: Cannot install in pnpm workspace root'));
p.note(
'You are initializing dexto in a pnpm workspace root. Go to a specific workspace package and run "pnpm add @dexto/core" there.',
chalk.rgb(255, 165, 0)('Workspace Error')
);
process.exit(1);
}
throw installError; // Re-throw other errors
}
spinner.stop('Dexto installed successfully!');
spinner.start('Creating Dexto files...');
// create dexto directories (dexto, dexto/agents)
const result = await createDextoDirectories(directory);
if (!result.ok) {
spinner.stop(
chalk.inverse(
`Dexto already initialized in ${path.join(directory, 'dexto')}. Would you like to overwrite it?`
)
);
const overwrite = await p.confirm({
message: 'Overwrite Dexto?',
initialValue: false,
});
if (p.isCancel(overwrite) || !overwrite) {
p.cancel('Dexto initialization cancelled');
process.exit(1);
}
}
// create dexto config file
logger.debug('Creating dexto config file...');
const dextoDir = path.join(directory, 'dexto');
const agentsDir = path.join(dextoDir, 'agents');
let configPath: string;
try {
configPath = await createDextoConfigFile(agentsDir);
logger.debug(`Dexto config file created at ${configPath}`);
} catch (configError) {
spinner.stop(chalk.red('Failed to create agent config file'));
logger.error(`Config creation error: ${configError}`);
throw new Error(
`Failed to create coding-agent.yml: ${configError instanceof Error ? configError.message : String(configError)}`
);
}
// update dexto config file based on llmProvider
if (llmProvider) {
logger.debug(`Updating dexto config file based on llmProvider: ${llmProvider}`);
await updateDextoConfigFile(configPath, llmProvider);
logger.debug(`Dexto config file updated with llmProvider: ${llmProvider}`);
}
// create dexto example file if requested
if (createExampleFile) {
logger.debug('Creating dexto example file...');
await createDextoExampleFile(dextoDir);
logger.debug('Dexto example file created successfully!');
}
// add/update .env file (only if user provided a key)
spinner.start('Saving API key to .env file...');
logger.debug(
`Saving API key: provider=${llmProvider ?? 'none'}, hasApiKey=${Boolean(llmApiKey)}`
);
if (llmProvider && llmApiKey) {
await saveProviderApiKey(llmProvider, llmApiKey, process.cwd());
}
spinner.stop('Saved .env updates');
} catch (err) {
spinner.stop(chalk.inverse(`An error occurred initializing Dexto project - ${err}`));
logger.debug(`Error: ${err}`);
process.exit(1);
}
}
/** Adds notes for users to get started with their new initialized Dexto project */
export async function postInitDexto(directory: string) {
const nextSteps = [
`1. Run the example: ${chalk.cyan(`node --loader ts-node/esm ${path.join(directory, 'dexto', 'dexto-example.ts')}`)}`,
`2. Add/update your API key(s) in ${chalk.cyan('.env')}`,
`3. Check out the agent configuration file ${chalk.cyan(path.join(directory, 'dexto', 'agents', 'coding-agent.yml'))}`,
`4. Try out different LLMs and MCP servers in the coding-agent.yml file`,
`5. Read more about Dexto: ${chalk.cyan('https://github.com/truffle-ai/dexto')}`,
].join('\n');
p.note(nextSteps, chalk.rgb(255, 165, 0)('Next steps:'));
}
/**
* Creates the dexto directories (dexto, dexto/agents) in the given directory.
* @param directory - The directory to create the dexto directories in
* @returns The path to the created dexto directory
*/
export async function createDextoDirectories(
directory: string
): Promise<{ ok: true; dirPath: string } | { ok: false }> {
const dirPath = path.join(directory, 'dexto');
const agentsPath = path.join(directory, 'dexto', 'agents');
try {
await fs.access(dirPath);
return { ok: false };
} catch {
// fsExtra.ensureDir creates directories recursively if they don't exist
await fsExtra.ensureDir(dirPath);
await fsExtra.ensureDir(agentsPath);
return { ok: true, dirPath };
}
}
/**
* Creates a dexto config file in the given directory. Pulls the config file from the installed Dexto package.
* @param directory - The directory to create the config file in
* @returns The path to the created config file
*/
export async function createDextoConfigFile(directory: string): Promise<string> {
// Ensure the directory exists
await fsExtra.ensureDir(directory);
try {
// Locate the Dexto package installation directory
const pkgJsonPath = require.resolve('dexto/package.json');
const pkgDir = path.dirname(pkgJsonPath);
logger.debug(`Package directory: ${pkgDir}`);
// Build path to the configuration template for create-app (with auto-approve toolConfirmation)
const templateConfigSrc = path.join(pkgDir, 'dist', 'agents', 'agent-template.yml');
logger.debug(`Looking for template at: ${templateConfigSrc}`);
// Check if template exists - fail if not found
const templateExists = await fsExtra.pathExists(templateConfigSrc);
if (!templateExists) {
throw new Error(
`Template file not found at: ${templateConfigSrc}. This indicates a build issue - the template should be included in the package.`
);
}
// Path to the destination config file
const destConfigPath = path.join(directory, 'coding-agent.yml');
logger.debug(`Copying template to: ${destConfigPath}`);
// Copy the config file from the Dexto package
await fsExtra.copy(templateConfigSrc, destConfigPath);
logger.debug(`Successfully created config file at: ${destConfigPath}`);
return destConfigPath;
} catch (error) {
logger.error(`Failed to create Dexto config file: ${error}`);
throw error;
}
}
/**
* Creates an example file in the given directory showing how to use Dexto in code. This file has example code to get you started.
* @param directory - The directory to create the example index file in
* @returns The path to the created example index file
*/
export async function createDextoExampleFile(directory: string): Promise<string> {
// Extract the base directory from the given path (e.g., "src" from "src/dexto")
const baseDir = path.dirname(directory);
const configPath = `./${path.posix.join(baseDir, 'dexto/agents/coding-agent.yml')}`;
const indexTsLines = [
"import 'dotenv/config';",
"import { DextoAgent, loadAgentConfig } from '@dexto/core';",
'',
"console.log('🚀 Starting Dexto Basic Example\\n');",
'',
'try {',
' // Load the agent configuration',
` const config = await loadAgentConfig('${configPath}');`,
'',
' // Create a new DextoAgent instance',
' const agent = new DextoAgent(config);',
'',
' // Start the agent (connects to MCP servers)',
" console.log('🔗 Connecting to MCP servers...');",
' await agent.start();',
" console.log('✅ Agent started successfully!\\n');",
'',
' // Create a session for this conversation',
' const session = await agent.createSession();',
" console.log('📝 Session created:', session.id, '\\n');",
'',
' // Example 1: Simple task',
" console.log('📋 Example 1: Simple information request');",
" const request1 = 'What tools do you have available?';",
" console.log('Request:', request1);",
' const response1 = await agent.run(request1, undefined, undefined, session.id);',
" console.log('Response:', response1);",
" console.log('\\n——————\\n');",
'',
' // Example 2: File operation',
" console.log('📄 Example 2: File creation');",
' const request2 = \'Create a file called test-output.txt with the content "Hello from Dexto!"\';',
" console.log('Request:', request2);",
' const response2 = await agent.run(request2, undefined, undefined, session.id);',
" console.log('Response:', response2);",
" console.log('\\n——————\\n');",
'',
' // Example 3: Multi-step conversation',
" console.log('🗣️ Example 3: Multi-step conversation');",
' const request3a = \'Create a simple HTML file called demo.html with a heading that says "Dexto Demo"\';',
" console.log('Request 3a:', request3a);",
' const response3a = await agent.run(request3a, undefined, undefined, session.id);',
" console.log('Response:', response3a);",
" console.log('\\n\\n');",
" const request3b = 'Now add a paragraph to that HTML file explaining what Dexto is';",
" console.log('Request 3b:', request3b);",
' const response3b = await agent.run(request3b, undefined, undefined, session.id);',
" console.log('Response:', response3b);",
" console.log('\\n——————\\n');",
'',
' // Reset conversation (clear context)',
" console.log('🔄 Resetting conversation context...');",
' await agent.resetConversation(session.id);',
" console.log('🔄 Conversation context reset');",
" console.log('\\n——————\\n');",
'',
' // Example 4: Complex task',
" console.log('🏗️ Example 4: Complex multi-tool task');",
' const request4 = ',
" 'Create a simple webpage about AI agents with HTML, CSS, and JavaScript. ' +",
" 'The page should have a title, some content about what AI agents are, ' +",
" 'and a button that shows an alert when clicked.';",
" console.log('Request:', request4);",
' const response4 = await agent.run(request4, undefined, undefined, session.id);',
" console.log('Response:', response4);",
" console.log('\\n——————\\n');",
'',
' // Stop the agent (disconnect from MCP servers)',
" console.log('\\n🛑 Stopping agent...');",
' await agent.stop();',
" console.log('✅ Agent stopped successfully!');",
'',
'} catch (error) {',
" console.error('❌ Error:', error);",
'}',
'',
"console.log('\\n📖 Read Dexto documentation to understand more about using Dexto: https://docs.dexto.ai');",
];
const indexTsContent = indexTsLines.join('\n');
const outputPath = path.join(directory, 'dexto-example.ts');
// Log the generated file content and paths for debugging
logger.debug(`Creating example file with config path: ${configPath}`);
logger.debug(`Base directory: ${baseDir}, Output path: ${outputPath}`);
logger.debug(`Generated file content:\n${indexTsContent}`);
// Ensure the directory exists before writing the file
await fs.writeFile(outputPath, indexTsContent);
return outputPath;
}

View File

@@ -0,0 +1,283 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
// Mock @dexto/core partially: preserve real exports and override specific functions
vi.mock('@dexto/core', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
...actual,
getDextoGlobalPath: vi.fn(),
};
});
// Mock @dexto/agent-management
vi.mock('@dexto/agent-management', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
...actual,
loadBundledRegistryAgents: vi.fn(),
};
});
// Mock agent-helpers
vi.mock('../../utils/agent-helpers.js', () => ({
installBundledAgent: vi.fn(),
installCustomAgent: vi.fn(),
listInstalledAgents: vi.fn(),
}));
// Mock fs
vi.mock('fs', () => ({
existsSync: vi.fn(),
statSync: vi.fn(),
}));
// Mock @clack/prompts
vi.mock('@clack/prompts', () => ({
intro: vi.fn(),
text: vi.fn(),
outro: vi.fn(),
cancel: vi.fn(),
isCancel: vi.fn(),
}));
// Mock analytics
vi.mock('../../analytics/index.js', () => ({
capture: vi.fn(),
}));
// Import SUT after mocks
import { handleInstallCommand } from './install.js';
import {
installBundledAgent,
installCustomAgent,
listInstalledAgents,
} from '../../utils/agent-helpers.js';
import { loadBundledRegistryAgents } from '@dexto/agent-management';
describe('Install Command', () => {
let consoleSpy: any;
const mockBundledRegistry = {
'test-agent': {
id: 'test-agent',
name: 'Test Agent',
description: 'Test agent',
author: 'Test',
tags: ['test'],
source: 'test.yml',
type: 'builtin' as const,
},
'other-agent': {
id: 'other-agent',
name: 'Other Agent',
description: 'Other agent',
author: 'Test',
tags: ['test'],
source: 'other.yml',
type: 'builtin' as const,
},
};
beforeEach(() => {
vi.clearAllMocks();
// Mock loadBundledRegistryAgents to return the mock registry directly
vi.mocked(loadBundledRegistryAgents).mockReturnValue(mockBundledRegistry);
// Mock agent helper functions
vi.mocked(installBundledAgent).mockResolvedValue('/mock/path/agent.yml');
vi.mocked(installCustomAgent).mockResolvedValue('/mock/path/custom-agent.yml');
vi.mocked(listInstalledAgents).mockResolvedValue([]);
// Mock existsSync to return false by default (agent not installed)
vi.mocked(fs.existsSync).mockReturnValue(false);
// Mock statSync to return file stats (default: file, not directory)
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => false,
} as any);
// Mock console
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
describe('Validation', () => {
it('throws error when no agents specified and all flag is false', async () => {
await expect(handleInstallCommand([], {})).rejects.toThrow();
});
it('throws error for unknown agents', async () => {
await expect(handleInstallCommand(['test-agent', 'unknown-agent'], {})).rejects.toThrow(
/Unknown agents.*unknown-agent/
);
});
it('accepts valid agents', async () => {
// Should not throw
await handleInstallCommand(['test-agent'], {});
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
});
});
describe('Single agent installation', () => {
it('installs single agent', async () => {
await handleInstallCommand(['test-agent'], {});
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('test-agent'));
});
it('respects force flag', async () => {
await handleInstallCommand(['test-agent'], { force: true });
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
});
it('skips already installed agents when force is false', async () => {
// Mock agent as already installed
vi.mocked(fs.existsSync).mockReturnValue(true);
await handleInstallCommand(['test-agent'], { force: false });
expect(installBundledAgent).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already installed'));
});
it('reinstalls already installed agents when force is true', async () => {
// Mock agent as already installed
vi.mocked(fs.existsSync).mockReturnValue(true);
await handleInstallCommand(['test-agent'], { force: true });
// Should still install despite existing
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
});
});
describe('Bulk installation (--all flag)', () => {
it('installs all available agents when --all flag is used', async () => {
await handleInstallCommand([], { all: true });
// Should install both agents from mockBundledRegistry
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
expect(installBundledAgent).toHaveBeenCalledWith('other-agent');
expect(installBundledAgent).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Installing all 2 available agents')
);
});
it('ignores agent list when --all flag is used', async () => {
await handleInstallCommand(['should-be-ignored'], { all: true });
// Should install bundled agents, not the specified one
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
expect(installBundledAgent).toHaveBeenCalledWith('other-agent');
expect(installBundledAgent).not.toHaveBeenCalledWith('should-be-ignored');
});
});
describe('Error handling', () => {
it('continues installing other agents when one fails', async () => {
vi.mocked(installBundledAgent).mockImplementation(async (agentId: string) => {
if (agentId === 'other-agent') {
throw new Error('Installation failed');
}
return '/path/to/agent.yml';
});
// Should not throw - partial success is acceptable
await handleInstallCommand(['test-agent', 'other-agent'], {});
expect(installBundledAgent).toHaveBeenCalledTimes(2);
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
expect(installBundledAgent).toHaveBeenCalledWith('other-agent');
});
it('throws when single agent installation fails', async () => {
vi.mocked(installBundledAgent).mockRejectedValue(new Error('Installation failed'));
// Single agent failure should propagate the error directly
await expect(handleInstallCommand(['test-agent'], {})).rejects.toThrow();
});
});
describe('Custom agent installation from file paths', () => {
let mockPrompts: any;
beforeEach(async () => {
const prompts = await import('@clack/prompts');
mockPrompts = {
intro: vi.mocked(prompts.intro),
text: vi.mocked(prompts.text),
outro: vi.mocked(prompts.outro),
isCancel: vi.mocked(prompts.isCancel),
};
// Default prompt responses
mockPrompts.text.mockImplementation(async (opts: any) => {
if (opts.message.includes('Agent name')) return 'my-custom-agent';
if (opts.message.includes('Description')) return 'Test description';
if (opts.message.includes('Author')) return 'Test Author';
if (opts.message.includes('Tags')) return 'custom, test';
return '';
});
mockPrompts.isCancel.mockReturnValue(false);
});
it('detects file paths and installs custom agent', async () => {
// Mock existsSync: source file exists, installed path does not
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
if (path.toString().includes('my-agent.yml')) return true;
return false; // Not installed yet
});
await handleInstallCommand(['./my-agent.yml'], {});
expect(installCustomAgent).toHaveBeenCalledWith(
'my-custom-agent',
expect.stringContaining('my-agent.yml'),
{
name: 'my-custom-agent',
description: 'Test description',
author: 'Test Author',
tags: ['custom', 'test'],
}
);
});
it('detects paths with forward slashes', async () => {
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
if (path.toString().includes('custom.yml')) return true;
return false;
});
await handleInstallCommand(['./agents/custom.yml'], {});
expect(installCustomAgent).toHaveBeenCalled();
});
it('validates file exists before installation', async () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
await expect(handleInstallCommand(['./nonexistent.yml'], {})).rejects.toThrow(
/File not found/
);
expect(installCustomAgent).not.toHaveBeenCalled();
});
it('treats non-path strings as registry names', async () => {
await handleInstallCommand(['test-agent'], {});
// Should use bundled agent installation, not custom
expect(installBundledAgent).toHaveBeenCalledWith('test-agent');
expect(installCustomAgent).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,359 @@
// packages/cli/src/cli/commands/install.ts
import { existsSync, statSync } from 'fs';
import path from 'path';
import { z } from 'zod';
import * as p from '@clack/prompts';
import { getDextoGlobalPath, loadBundledRegistryAgents } from '@dexto/agent-management';
import { textOrExit } from '../utils/prompt-helpers.js';
import { installBundledAgent, installCustomAgent } from '../../utils/agent-helpers.js';
import { capture } from '../../analytics/index.js';
// Zod schema for install command validation
const InstallCommandSchema = z
.object({
agents: z.array(z.string().min(1, 'Agent name cannot be empty')),
all: z.boolean().default(false),
force: z.boolean().default(false),
})
.strict();
export type InstallCommandOptions = z.output<typeof InstallCommandSchema>;
/**
* Check if a string is a file path (contains path separators or ends with .yml)
*/
function isFilePath(input: string): boolean {
return (
input.includes('/') ||
input.includes('\\') ||
input.endsWith('.yml') ||
input.endsWith('.yaml')
);
}
/**
* Extract agent name from file path and sanitize for validity
* Agent names must be lowercase alphanumeric with hyphens only.
* Examples:
* './my-agent.yml' -> 'my-agent'
* './my_agent.yml' -> 'my-agent' (underscore converted)
* './agents/foo/agent.yml' -> 'agent'
* './MyAgent.yml' -> 'myagent'
*/
function extractAgentNameFromPath(filePath: string): string {
const basename = path.basename(filePath);
// If it's a file, remove the extension
let name = basename;
if (basename.endsWith('.yml') || basename.endsWith('.yaml')) {
name = basename.replace(/\.(yml|yaml)$/, '');
}
// Sanitize: lowercase, replace underscores and invalid chars with hyphens
name = name
.toLowerCase()
.replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
.replace(/[^a-z0-9-]/g, '') // Remove any other invalid characters
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
return name;
}
/**
* Prompt user for custom agent metadata
*/
async function promptForMetadata(suggestedName: string): Promise<{
agentName: string;
description: string;
author: string;
tags: string[];
}> {
p.intro('📝 Custom Agent Installation');
const agentName = await textOrExit(
{
message: 'Agent name:',
placeholder: suggestedName,
defaultValue: suggestedName,
validate: (value) => {
if (!value || value.trim().length === 0) {
return 'Agent name is required';
}
if (!/^[a-z0-9-]+$/.test(value)) {
return 'Agent name must contain only lowercase letters, numbers, and hyphens';
}
return undefined;
},
},
'Installation cancelled'
);
const description = await textOrExit(
{
message: 'Description:',
placeholder: 'A custom agent for...',
validate: (value) => {
if (!value || value.trim().length === 0) {
return 'Description is required';
}
return undefined;
},
},
'Installation cancelled'
);
const author = await textOrExit(
{
message: 'Author:',
placeholder: 'Your Name',
validate: (value) => {
if (!value || value.trim().length === 0) {
return 'Author is required';
}
return undefined;
},
},
'Installation cancelled'
);
const tagsInput = await textOrExit(
{
message: 'Tags (comma-separated):',
placeholder: 'custom, coding, productivity',
defaultValue: 'custom',
},
'Installation cancelled'
);
const tags = tagsInput
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
// Ask about main config file for directory-based agents
// We'll determine if it's a directory later in the flow
return { agentName, description, author, tags };
}
/**
* Validate install command arguments with registry-aware validation
*/
function validateInstallCommand(
agents: string[],
options: Partial<InstallCommandOptions>
): InstallCommandOptions {
// Basic structure validation
const validated = InstallCommandSchema.parse({
...options,
agents,
});
// Business logic validation
const availableAgents = loadBundledRegistryAgents();
if (!validated.all && validated.agents.length === 0) {
throw new Error(
`No agents specified. Use agent names or --all flag. Available agents: ${Object.keys(availableAgents).join(', ')}`
);
}
if (!validated.all) {
// Separate file paths from registry names
const filePaths = validated.agents.filter(isFilePath);
const registryNames = validated.agents.filter((agent) => !isFilePath(agent));
// Validate registry names exist in registry
const invalidAgents = registryNames.filter((agent) => !(agent in availableAgents));
if (invalidAgents.length > 0) {
throw new Error(
`Unknown agents: ${invalidAgents.join(', ')}. ` +
`Available agents: ${Object.keys(availableAgents).join(', ')}`
);
}
// Validate file paths exist
for (const filePath of filePaths) {
const resolved = path.resolve(filePath);
if (!existsSync(resolved)) {
throw new Error(`File not found: ${filePath}`);
}
}
}
return validated;
}
// TODO: move registry code into CLI and move dexto_install_agent metric into registry
export async function handleInstallCommand(
agents: string[],
options: Partial<InstallCommandOptions>
): Promise<void> {
// Validate command with Zod
const validated = validateInstallCommand(agents, options);
// Determine which agents to install
let agentsToInstall: string[];
if (validated.all) {
// --all flag only works with registry agents, not file paths
const availableAgents = loadBundledRegistryAgents();
agentsToInstall = Object.keys(availableAgents);
console.log(`📋 Installing all ${agentsToInstall.length} available agents...`);
} else {
agentsToInstall = validated.agents;
}
console.log(`🚀 Installing ${agentsToInstall.length} agents...`);
let successCount = 0;
let errorCount = 0;
const errors: string[] = [];
const installed: string[] = [];
const skipped: string[] = [];
const failed: string[] = [];
// Install each agent
for (const agentInput of agentsToInstall) {
try {
// Check if this is a file path or registry name
if (isFilePath(agentInput)) {
// Custom agent installation from file path
console.log(`\n📦 Installing custom agent from ${agentInput}...`);
const resolvedPath = path.resolve(agentInput);
// Detect if source is directory or file
const stats = statSync(resolvedPath);
const isDirectory = stats.isDirectory();
// Extract suggested name based on whether it's a directory or file
const suggestedName = isDirectory
? path.basename(resolvedPath)
: extractAgentNameFromPath(resolvedPath);
// Prompt for metadata
const metadata = await promptForMetadata(suggestedName);
// Check if already installed (unless --force)
const globalAgentsDir = getDextoGlobalPath('agents');
const installedPath = path.join(globalAgentsDir, metadata.agentName);
if (existsSync(installedPath) && !validated.force) {
console.log(
`⏭️ ${metadata.agentName} already installed (use --force to reinstall)`
);
skipped.push(metadata.agentName);
capture('dexto_install_agent', {
agent: metadata.agentName,
status: 'skipped',
reason: 'already_installed',
force: validated.force,
});
continue;
}
// Install custom agent
await installCustomAgent(metadata.agentName, resolvedPath, {
name: metadata.agentName,
description: metadata.description,
author: metadata.author,
tags: metadata.tags,
});
successCount++;
console.log(`${metadata.agentName} installed successfully`);
installed.push(metadata.agentName);
p.outro('🎉 Custom agent installed successfully!');
capture('dexto_install_agent', {
agent: metadata.agentName,
status: 'installed',
force: validated.force,
});
} else {
// Bundled agent installation from registry
console.log(`\n📦 Installing ${agentInput}...`);
// Check if already installed (unless --force)
const globalAgentsDir = getDextoGlobalPath('agents');
const installedPath = path.join(globalAgentsDir, agentInput);
if (existsSync(installedPath) && !validated.force) {
console.log(`⏭️ ${agentInput} already installed (use --force to reinstall)`);
skipped.push(agentInput);
capture('dexto_install_agent', {
agent: agentInput,
status: 'skipped',
reason: 'already_installed',
force: validated.force,
});
continue;
}
await installBundledAgent(agentInput);
successCount++;
console.log(`${agentInput} installed successfully`);
installed.push(agentInput);
capture('dexto_install_agent', {
agent: agentInput,
status: 'installed',
force: validated.force,
});
}
} catch (error) {
errorCount++;
const errorMsg = `Failed to install ${agentInput}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(errorMsg);
failed.push(agentInput);
console.error(`${errorMsg}`);
// Sanitize agent identifier for analytics (avoid sending full local paths)
const safeAgentId = isFilePath(agentInput) ? path.basename(agentInput) : agentInput;
capture('dexto_install_agent', {
agent: safeAgentId,
status: 'failed',
error_message: error instanceof Error ? error.message : String(error),
force: validated.force,
});
}
}
// Emit analytics for both single- and multi-agent cases
try {
capture('dexto_install', {
requested: agentsToInstall,
installed,
skipped,
failed,
successCount,
errorCount,
});
} catch {
// Analytics failures should not block CLI execution.
}
// For single agent operations, throw error if it failed (after emitting analytics)
if (agentsToInstall.length === 1) {
if (errorCount > 0) {
throw new Error(errors[0]);
}
return;
}
// Show summary if more than 1 agent installed
console.log(`\n📊 Installation Summary:`);
console.log(`✅ Successfully installed: ${successCount}`);
if (errorCount > 0) {
console.log(`❌ Failed to install: ${errorCount}`);
errors.forEach((error) => console.log(`${error}`));
}
if (errorCount > 0 && successCount === 0) {
throw new Error('All installations failed');
} else if (errorCount > 0) {
console.log(`⚠️ Some installations failed, but ${successCount} succeeded.`);
} else {
console.log(`🎉 All agents installed successfully!`);
}
}

View File

@@ -0,0 +1,23 @@
/**
* Auth Commands Module
*
* Authentication commands for interactive CLI.
*/
import type { CommandDefinition } from '../command-parser.js';
import { handleLoginCommand } from '../../auth/login.js';
/**
* Login command - triggers OAuth flow for Dexto authentication
* Only available when DEXTO_FEATURE_AUTH=true
*/
export const loginCommand: CommandDefinition = {
name: 'login',
description: 'Login to Dexto',
usage: '/login',
category: 'General',
handler: async () => {
await handleLoginCommand({ interactive: true });
return true;
},
};

View File

@@ -0,0 +1,237 @@
import chalk from 'chalk';
import type { DextoAgent } from '@dexto/core';
import type { StyledOutput, SendMessageMarker } from '../../ink-cli/services/CommandService.js';
export interface CommandResult {
type: 'command' | 'prompt';
command?: string;
args?: string[];
rawInput: string;
}
/**
* Command handler return type:
* - boolean: Command handled (true) or not found (false)
* - string: Output text to display
* - StyledOutput: Styled output with structured data for rich rendering
* - SendMessageMarker: Send text through normal streaming flow (for prompt commands)
*/
export type CommandHandlerResult = boolean | string | StyledOutput | SendMessageMarker;
/**
* Context passed to command handlers
*/
export interface CommandContext {
/** Current session ID, or null if no active session */
sessionId: string | null;
}
export interface CommandDefinition {
name: string;
description: string;
usage: string;
category?: string;
aliases?: string[];
subcommands?: CommandDefinition[];
handler: (
args: string[],
agent: DextoAgent,
ctx: CommandContext
) => Promise<CommandHandlerResult>;
}
/**
* No-op handler for overlay-only commands.
* Used by commands in ALWAYS_OVERLAY that are handled entirely by the overlay system.
*/
export const overlayOnlyHandler = async (): Promise<CommandHandlerResult> => true;
/**
* Parse arguments respecting quotes and escape sequences
*/
function parseQuotedArguments(input: string): string[] {
const args: string[] = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
let i = 0;
while (i < input.length) {
const char = input[i];
const nextChar = input[i + 1];
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
} else if (inQuotes && char === quoteChar) {
inQuotes = false;
quoteChar = '';
} else if (!inQuotes && char === ' ') {
if (current) {
args.push(current);
current = '';
}
} else if (char === '\\' && nextChar) {
// Handle escape sequences
current += nextChar;
i++; // Skip next character
} else {
current += char;
}
i++;
}
if (current) {
args.push(current);
}
return args.filter((arg) => arg.length > 0);
}
/**
* Parses user input to determine if it's a slash command, shell command, or regular prompt
*/
export function parseInput(input: string): CommandResult {
const trimmed = input.trim();
// Check if it's a shell command (! prefix)
if (trimmed.startsWith('!')) {
const shellCommand = trimmed.slice(1).trim();
return {
type: 'command',
command: 'shell',
args: [shellCommand],
rawInput: trimmed,
};
}
// Check if it's a slash command
if (trimmed.startsWith('/')) {
const args = parseQuotedArguments(trimmed.slice(1));
const command = args[0] || '';
const commandArgs = args.slice(1);
return {
type: 'command',
command,
args: commandArgs,
rawInput: trimmed,
};
}
// Regular user prompt
return {
type: 'prompt',
rawInput: input,
};
}
/**
* Finds command suggestions based on partial input
*/
export function getCommandSuggestions(partial: string, commands: CommandDefinition[]): string[] {
const suggestions: string[] = [];
for (const cmd of commands) {
// Check main command name
if (cmd.name.startsWith(partial)) {
suggestions.push(cmd.name);
}
// Check aliases
if (cmd.aliases) {
for (const alias of cmd.aliases) {
if (alias.startsWith(partial)) {
suggestions.push(alias);
}
}
}
}
return suggestions.sort();
}
/**
* Formats help text for a command
*/
export function formatCommandHelp(cmd: CommandDefinition, detailed: boolean = false): string {
let help = chalk.cyan(`/${cmd.name}`) + ' - ' + cmd.description;
if (detailed) {
help += '\n' + chalk.gray(`Usage: ${cmd.usage}`);
if (cmd.aliases && cmd.aliases.length > 0) {
help += '\n' + chalk.gray(`Aliases: ${cmd.aliases.map((a) => `/${a}`).join(', ')}`);
}
if (cmd.subcommands && cmd.subcommands.length > 0) {
help += '\n' + chalk.gray('Subcommands:');
for (const sub of cmd.subcommands) {
help += '\n ' + chalk.cyan(`/${cmd.name} ${sub.name}`) + ' - ' + sub.description;
}
}
}
return help;
}
/**
* Displays a formatted list of all available commands
*/
export function displayAllCommands(commands: CommandDefinition[]): void {
console.log(chalk.bold.green('\n📋 Available Commands:\n'));
// Define category order for consistent display
const categoryOrder = [
'General',
'Session Management',
'Model Management',
'MCP Management',
'Plugin Management',
'Tool Management',
'Prompt Management',
'System',
];
const categories: { [key: string]: CommandDefinition[] } = {};
// Initialize categories
for (const category of categoryOrder) {
categories[category] = [];
}
// Categorize commands using metadata
for (const cmd of commands) {
const category = cmd.category || 'General';
if (!categories[category]) {
categories[category] = [];
}
categories[category]!.push(cmd);
}
// Display by category in order
for (const category of categoryOrder) {
const cmds = categories[category];
if (cmds && cmds.length > 0) {
console.log(chalk.bold.rgb(255, 165, 0)(`${category}:`));
for (const cmd of cmds) {
console.log(' ' + formatCommandHelp(cmd));
}
console.log();
}
}
// Display any uncategorized commands (fallback)
for (const [category, cmds] of Object.entries(categories)) {
if (!categoryOrder.includes(category) && cmds.length > 0) {
console.log(chalk.bold.rgb(255, 165, 0)(`${category}:`));
for (const cmd of cmds) {
console.log(' ' + formatCommandHelp(cmd));
}
console.log();
}
}
console.log(chalk.gray('💡 Tip: Use /help <command> for detailed help on any command'));
console.log(chalk.gray('💡 Tip: Type your message normally (without /) to chat with the AI\n'));
}

View File

@@ -0,0 +1,176 @@
/**
* CLI Commands Module (Modular Version)
*
* This module aggregates all CLI commands from extracted modular components.
* It maintains the same external interface as the original monolithic commands.ts
* while using the new modular structure internally.
*
* The commands are organized into logical modules:
* - General Commands: Basic CLI functionality (help, exit, clear)
* - Conversation Commands: Session management, history, and search
* - Model Commands: Model switching and configuration
* - MCP Commands: MCP server management
* - Plugin Commands: Claude Code plugin management
* - System Commands: Configuration, logging, and statistics
* - Tool Commands: Tool listing and management
* - Prompt Commands: System prompt management
* - Documentation Commands: Help and documentation access
*
* This file serves as the integration layer that combines all modular commands
* into a single CLI_COMMANDS array for the command execution system.
*/
import type { DextoAgent } from '@dexto/core';
import type { CommandDefinition, CommandHandlerResult } from './command-parser.js';
import { isDextoAuthEnabled } from '@dexto/agent-management';
// Import modular command definitions
import { generalCommands, createHelpCommand } from './general-commands.js';
import { searchCommand, resumeCommand, renameCommand } from './session/index.js';
import { exportCommand } from './export/index.js';
import { modelCommands } from './model/index.js';
import { mcpCommands } from './mcp/index.js';
import { pluginCommands } from './plugin/index.js';
import { systemCommands } from './system/index.js';
import { toolCommands } from './tool-commands.js';
import { promptCommands } from './prompt-commands.js';
import { documentationCommands } from './documentation-commands.js';
import { loginCommand } from './auth/index.js';
/**
* Complete list of all available CLI commands.
* This array combines commands from all extracted modules to maintain
* the same interface as the original monolithic implementation.
*
* Commands are organized by category:
* - General: help, exit, clear
* - Session Management: session, history, search
* - Model Management: model
* - MCP Management: mcp
* - Tool Management: tools
* - Prompt Management: prompt
* - System: log, config, stats
* - Documentation: docs
*/
export const CLI_COMMANDS: CommandDefinition[] = [];
// Build the commands array with proper help command that can access all commands
// All commands here use interactive overlays - no text-based subcommands
const baseCommands: CommandDefinition[] = [
// General commands (without help)
...generalCommands,
// Session management
searchCommand, // /search - opens search overlay
resumeCommand, // /resume - opens session selector overlay
renameCommand, // /rename <title> - rename current session
exportCommand, // /export - opens export wizard overlay
// Model management
modelCommands, // /model - opens model selector overlay
// MCP server management
mcpCommands, // /mcp - opens MCP server list overlay
// Plugin management
pluginCommands, // /plugin - manage Claude Code compatible plugins
// Tool management commands
...toolCommands,
// Prompt management commands
...promptCommands,
// System commands
...systemCommands,
// Documentation commands
...documentationCommands,
// Auth commands (feature-flagged)
...(isDextoAuthEnabled() ? [loginCommand] : []),
];
// Add help command that can see all commands
CLI_COMMANDS.push(createHelpCommand(() => CLI_COMMANDS));
// Add all other commands
CLI_COMMANDS.push(...baseCommands);
/**
* Execute a slash command
*
* @param sessionId - Session ID to use for agent.run() calls
* @returns CommandHandlerResult - boolean, string, or StyledOutput
*/
export async function executeCommand(
command: string,
args: string[],
agent: DextoAgent,
sessionId?: string
): Promise<CommandHandlerResult> {
// Create command context with sessionId
const ctx = { sessionId: sessionId ?? null };
// Find the command (including aliases)
const cmd = CLI_COMMANDS.find(
(c) => c.name === command || (c.aliases && c.aliases.includes(command))
);
if (cmd) {
try {
// Execute the handler with context
const result = await cmd.handler(args, agent, ctx);
// If handler returns a string, it's formatted output for ink-cli
// If it returns boolean, it's the old behavior (handled or not)
return result;
} catch (error) {
const errorMsg = `❌ Error executing command /${command}:\n${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(
`Error executing command /${command}: ${error instanceof Error ? error.message : String(error)}`
);
return errorMsg; // Return for ink-cli
}
}
// Command not found in static commands - check if it's a dynamic prompt command
// Dynamic commands use displayName (e.g., "quick-start" instead of "config:quick-start")
try {
// Import prompt command creation dynamically to avoid circular dependencies
const { getDynamicPromptCommands } = await import('./prompt-commands.js');
const dynamicCommands = await getDynamicPromptCommands(agent);
// Commands are registered by displayName, so search by command name directly
const promptCmd = dynamicCommands.find((c) => c.name === command);
if (promptCmd) {
try {
const result = await promptCmd.handler(args, agent, ctx);
// Return the result directly - can be string, boolean, StyledOutput, or SendMessageMarker
return result;
} catch (error) {
const errorMsg = `❌ Error executing prompt /${command}:\n${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(
`Error executing prompt /${command}: ${error instanceof Error ? error.message : String(error)}`
);
return errorMsg;
}
}
} catch (error) {
// If loading dynamic commands fails, continue to unknown command error
agent.logger.debug(
`Failed to check dynamic commands for ${command}: ${error instanceof Error ? error.message : String(error)}`
);
}
// Command not found and not a prompt
const errorMsg = `❌ Unknown command: /${command}\nType / to see available commands, /prompts to add new ones`;
return errorMsg; // Return for ink-cli
}
/**
* Get all available command definitions
* This is used by external systems that need to inspect available commands
*/
export function getAllCommands(): CommandDefinition[] {
return CLI_COMMANDS;
}

View File

@@ -0,0 +1,52 @@
/**
* Documentation Commands Module
*
* This module defines documentation-related slash commands for the Dexto CLI interface.
* These commands provide functionality for accessing documentation and help resources.
*
* Available Documentation Commands:
* - /docs, /doc - Open Dexto documentation in browser
*/
import type { DextoAgent } from '@dexto/core';
import type { CommandDefinition, CommandContext } from './command-parser.js';
import { CommandOutputHelper } from './utils/command-output.js';
/**
* Documentation commands
*/
export const documentationCommands: CommandDefinition[] = [
{
name: 'docs',
description: 'Open Dexto documentation in browser',
usage: '/docs',
category: 'Documentation',
aliases: ['doc'],
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean | string> => {
const docsUrl = 'https://docs.dexto.ai/docs/category/getting-started';
try {
const { spawn } = await import('child_process');
// Cross-platform browser opening
const command =
process.platform === 'darwin'
? 'open'
: process.platform === 'win32'
? 'start'
: 'xdg-open';
spawn(command, [docsUrl], { detached: true, stdio: 'ignore' });
return true; // Silent success
} catch {
return CommandOutputHelper.error(
new Error(`Could not open browser. Visit: ${docsUrl}`),
'Failed to open documentation'
);
}
},
},
];

View File

@@ -0,0 +1,29 @@
/**
* Export Command Module
*
* Provides the /export command for exporting conversation history.
* Always shows the interactive export wizard overlay.
*/
import type { CommandDefinition, CommandContext, CommandHandlerResult } from '../command-parser.js';
import type { DextoAgent } from '@dexto/core';
/**
* Export command definition
* Always shows the interactive export wizard overlay (handled by ALWAYS_OVERLAY)
*/
export const exportCommand: CommandDefinition = {
name: 'export',
description: 'Export conversation to markdown or JSON',
usage: '/export',
category: 'Session',
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<CommandHandlerResult> => {
// This handler is never called - export is in ALWAYS_OVERLAY
// which intercepts and shows the export wizard overlay instead
return true;
},
};

View File

@@ -0,0 +1,562 @@
/**
* General Commands Module
*
* This module defines general-purpose slash commands for the Dexto CLI interface.
* These are basic commands that don't fit into specific categories.
*
* Available General Commands:
* - /help [command] - Show help information
* - /exit, /quit, /q - Exit the CLI application
* - /clear, /reset - Clear conversation history
*/
import chalk from 'chalk';
import { spawn } from 'child_process';
import type { DextoAgent } from '@dexto/core';
import type { CommandDefinition, CommandHandlerResult, CommandContext } from './command-parser.js';
import { formatForInkCli } from './utils/format-output.js';
import { CommandOutputHelper } from './utils/command-output.js';
import type { HelpStyledData, ShortcutsStyledData } from '../../ink-cli/state/types.js';
import { writeToClipboard } from '../../ink-cli/utils/clipboardUtils.js';
/**
* Get the shell rc file path for the given shell
*/
function getShellRcFile(shell: string): string | null {
const home = process.env.HOME;
if (!home) return null;
if (shell.includes('zsh')) {
return `${home}/.zshrc`;
} else if (shell.includes('bash')) {
return `${home}/.bashrc`;
}
return null;
}
/**
* Wrap command to source shell rc file for alias support
* This mimics how Claude Code handles shell aliases - by sourcing the rc file
* before executing the command, we get aliases without using -i flag.
* We use eval to force the command to be re-parsed after sourcing, since
* aliases are expanded at parse time, not execution time.
*/
function wrapCommandWithRcSource(command: string, shell: string): string {
const rcFile = getShellRcFile(shell);
if (!rcFile) {
return command;
}
// Escape single quotes in the command for safe eval
const escapedCommand = command.replace(/'/g, "'\\''");
// Source the rc file (suppressing errors if it doesn't exist), then eval the command
// eval forces re-parsing after sourcing, allowing aliases to expand
// For bash, we also need to enable expand_aliases
if (shell.includes('bash')) {
return `source "${rcFile}" 2>/dev/null; shopt -s expand_aliases 2>/dev/null; eval '${escapedCommand}'`;
}
return `source "${rcFile}" 2>/dev/null; eval '${escapedCommand}'`;
}
async function executeShellCommand(
command: string,
cwd: string,
timeoutMs: number = 30000
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolve) => {
// Use user's default shell from SHELL env var, fallback to /bin/sh
// Avoid -i (interactive) as it sets up job control which causes SIGTTIN
// when the parent process tries to read stdin while shell runs.
// Instead, source the shell's rc file to get aliases (similar to Claude Code).
// Use detached: true to create a new process group, preventing the child
// from interfering with the parent's terminal control.
const userShell = process.env.SHELL || '/bin/sh';
const wrappedCommand = wrapCommandWithRcSource(command, userShell);
const child = spawn(userShell, ['-c', wrappedCommand], {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
detached: true,
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill();
resolve({ stdout, stderr: `Command timed out after ${timeoutMs}ms`, exitCode: -1 });
}, timeoutMs);
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('error', (error: Error) => {
clearTimeout(timer);
resolve({ stdout, stderr: error.message, exitCode: -1 });
});
child.on('close', (code: number | null) => {
clearTimeout(timer);
resolve({ stdout, stderr, exitCode: code ?? -1 });
});
});
}
/**
* Creates the help command with access to all commands for display
*/
export function createHelpCommand(getAllCommands: () => CommandDefinition[]): CommandDefinition {
return {
name: 'help',
description: 'Show help information',
usage: '/help',
category: 'General',
aliases: ['h', '?'],
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<CommandHandlerResult> => {
const allCommands = getAllCommands();
// Build styled data for help
const styledData: HelpStyledData = {
commands: allCommands.map((cmd) => ({
name: cmd.name,
description: cmd.description,
category: cmd.category || 'General',
})),
};
// Build fallback text
const fallbackLines: string[] = ['Available Commands:'];
for (const cmd of allCommands) {
fallbackLines.push(` /${cmd.name} - ${cmd.description}`);
}
return CommandOutputHelper.styled('help', styledData, fallbackLines.join('\n'));
},
};
}
/**
* General commands that are available across all contexts
* Note: The help command is created separately to avoid circular dependencies
*/
export const generalCommands: CommandDefinition[] = [
{
name: 'shell',
description: 'Execute shell command directly (use !command as shortcut)',
usage: '!<command> or /shell <command>',
category: 'General',
handler: async (
args: string[],
agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean | string> => {
const command = args.join(' ').trim();
if (!command) {
return formatForInkCli('❌ No command provided. Usage: !<command>');
}
const cwd = process.cwd();
agent.logger.debug(`Executing shell command: ${command}`);
const { stdout, stderr, exitCode } = await executeShellCommand(command, cwd);
// Build output
const lines: string[] = [];
if (stdout.trim()) {
lines.push(stdout.trim());
}
if (stderr.trim()) {
lines.push(chalk.yellow(stderr.trim()));
}
if (exitCode !== 0) {
lines.push(chalk.red(`Exit code: ${exitCode}`));
}
if (lines.length === 0) {
return formatForInkCli(chalk.gray('(no output)'));
}
return formatForInkCli(lines.join('\n'));
},
},
{
name: 'exit',
description: 'Exit the CLI',
usage: '/exit',
category: 'General',
aliases: ['quit', 'q'],
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean | string> => {
console.log(chalk.rgb(255, 165, 0)('Exiting AI CLI. Goodbye!'));
process.exit(0);
},
},
{
name: 'new',
description: 'Start a new conversation',
usage: '/new',
category: 'General',
handler: async (
_args: string[],
agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean | string> => {
try {
// Create a new session
const newSession = await agent.createSession();
const newSessionId = newSession.id;
// Emit session:created to switch the CLI to the new session
agent.agentEventBus.emit('session:created', {
sessionId: newSessionId,
switchTo: true,
});
return formatForInkCli(
`✨ New conversation started\n💡 Use /resume to see previous conversations`
);
} catch (error) {
const errorMsg = `Failed to create new session: ${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(errorMsg);
return formatForInkCli(`${errorMsg}`);
}
},
},
{
name: 'clear',
description: 'Continue conversation, free up AI context window',
usage: '/clear',
category: 'General',
handler: async (
_args: string[],
agent: DextoAgent,
ctx: CommandContext
): Promise<boolean | string> => {
try {
const { sessionId } = ctx;
if (!sessionId) {
return formatForInkCli('⚠️ No active session to clear');
}
// Clear context window - adds a marker so filterCompacted skips prior messages
// History stays in DB for review, but LLM won't see it
await agent.clearContext(sessionId);
return formatForInkCli(
'🧹 Context window cleared\n💡 Conversation continues - AI will not see older messages'
);
} catch (error) {
const errorMsg = `Failed to clear context: ${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(errorMsg);
return formatForInkCli(`${errorMsg}`);
}
},
},
{
name: 'compact',
description: 'Compact context by summarizing older messages',
usage: '/compact',
category: 'General',
aliases: ['summarize'],
handler: async (
_args: string[],
agent: DextoAgent,
ctx: CommandContext
): Promise<boolean | string> => {
try {
const { sessionId } = ctx;
if (!sessionId) {
return formatForInkCli('⚠️ No active session to compact');
}
// Compact context - generates summary and hides older messages
// The context:compacting and context:compacted events are handled by useAgentEvents
// which shows the compacting indicator and notification message
const result = await agent.compactContext(sessionId);
if (!result) {
return formatForInkCli(
'💡 Nothing to compact - history is too short or compaction is not configured.'
);
}
// Return true - notification is shown via context:compacted event handler
return true;
} catch (error) {
const errorMsg = `Failed to compact context: ${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(errorMsg);
return formatForInkCli(`${errorMsg}`);
}
},
},
{
name: 'context',
description: 'Show context window usage statistics',
usage: '/context',
category: 'General',
aliases: ['ctx', 'tokens'],
handler: async (
_args: string[],
agent: DextoAgent,
ctx: CommandContext
): Promise<boolean | string> => {
try {
const { sessionId } = ctx;
if (!sessionId) {
return formatForInkCli('⚠️ No active session');
}
const stats = await agent.getContextStats(sessionId);
// Create a visual progress bar (clamped to 0-100% for display)
const barWidth = 20;
const displayPercent = Math.min(Math.max(stats.usagePercent, 0), 100);
const filledWidth = Math.round((displayPercent / 100) * barWidth);
const emptyWidth = barWidth - filledWidth;
const progressBar = '█'.repeat(filledWidth) + '░'.repeat(emptyWidth);
// Color based on usage
let usageColor = chalk.green;
if (stats.usagePercent > 80) usageColor = chalk.red;
else if (stats.usagePercent > 60) usageColor = chalk.yellow;
// Helper to format token counts
const formatTokens = (tokens: number): string => {
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return tokens.toLocaleString();
};
// Calculate auto compact buffer (reserved space before compaction triggers)
// maxContextTokens already has thresholdPercent applied, so we need to derive
// the buffer as: maxContextTokens * (1 - thresholdPercent) / thresholdPercent
const autoCompactBuffer =
stats.thresholdPercent > 0 && stats.thresholdPercent < 1.0
? Math.floor(
(stats.maxContextTokens * (1 - stats.thresholdPercent)) /
stats.thresholdPercent
)
: 0;
const bufferPercent = Math.round((1 - stats.thresholdPercent) * 100);
const bufferLabel =
bufferPercent > 0
? `Auto compact buffer (${bufferPercent}%)`
: 'Auto compact buffer';
const totalTokenSpace = stats.maxContextTokens + autoCompactBuffer;
const usedTokens = stats.estimatedTokens + autoCompactBuffer;
// Helper to calculate percentage of total token space
const pct = (tokens: number): string => {
const percent =
totalTokenSpace > 0 ? ((tokens / totalTokenSpace) * 100).toFixed(1) : '0.0';
return `${percent}%`;
};
const overflowWarning = stats.usagePercent > 100 ? ' ⚠️ OVERFLOW' : '';
const { breakdown } = stats;
const tokenDisplay = `~${formatTokens(usedTokens)}`;
const breakdownLabel = chalk.dim('(estimated)');
const lines = [
`📊 Context Usage`,
` ${usageColor(progressBar)} ${stats.usagePercent}%${overflowWarning}`,
` ${chalk.dim(stats.modelDisplayName)} · ${tokenDisplay} / ${formatTokens(totalTokenSpace)} tokens`,
``,
` ${chalk.cyan('Breakdown:')} ${breakdownLabel}`,
` ├─ System prompt: ${formatTokens(breakdown.systemPrompt)} (${pct(breakdown.systemPrompt)})`,
` ├─ Tools: ${formatTokens(breakdown.tools.total)} (${pct(breakdown.tools.total)})`,
` ├─ Messages: ${formatTokens(breakdown.messages)} (${pct(breakdown.messages)})`,
` └─ ${bufferLabel}: ${formatTokens(autoCompactBuffer)} (${pct(autoCompactBuffer)})`,
``,
` Messages: ${stats.filteredMessageCount} visible (${stats.messageCount} total)`,
];
// Show pruned tool count if any
if (stats.prunedToolCount > 0) {
lines.push(` 🗑️ ${stats.prunedToolCount} tool output(s) pruned`);
}
if (stats.hasSummary) {
lines.push(` 📦 Context has been compacted`);
}
if (stats.usagePercent > 100) {
lines.push(
` 💡 Use /compact to manually compact, or send a message to trigger auto-compaction`
);
}
return formatForInkCli(lines.join('\n'));
} catch (error) {
const errorMsg = `Failed to get context stats: ${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(errorMsg);
return formatForInkCli(`${errorMsg}`);
}
},
},
{
name: 'copy',
description: 'Copy the last assistant response to clipboard',
usage: '/copy',
category: 'General',
aliases: ['cp'],
handler: async (
_args: string[],
agent: DextoAgent,
ctx: CommandContext
): Promise<boolean | string> => {
try {
const { sessionId } = ctx;
if (!sessionId) {
return formatForInkCli('❌ No active session');
}
// Get session history
const history = await agent.getSessionHistory(sessionId);
if (!history || history.length === 0) {
return formatForInkCli('❌ No messages in current session');
}
// Find the last assistant message
const lastAssistantMessage = [...history]
.reverse()
.find((msg) => msg.role === 'assistant');
if (!lastAssistantMessage) {
return formatForInkCli('❌ No assistant response to copy');
}
// Extract text content from the message
let textContent = '';
if (typeof lastAssistantMessage.content === 'string') {
textContent = lastAssistantMessage.content;
} else if (Array.isArray(lastAssistantMessage.content)) {
// Handle multi-part content
textContent = lastAssistantMessage.content
.filter(
(part): part is { type: 'text'; text: string } => part.type === 'text'
)
.map((part) => part.text)
.join('\n');
}
if (!textContent) {
return formatForInkCli('❌ No text content to copy');
}
// Copy to clipboard
const success = await writeToClipboard(textContent);
if (success) {
const preview =
textContent.length > 50
? textContent.substring(0, 50) + '...'
: textContent;
return formatForInkCli(
`📋 Copied to clipboard (${textContent.length} chars)\n${preview.replace(/\n/g, ' ')}`
);
} else {
return formatForInkCli('❌ Failed to copy to clipboard');
}
} catch (error) {
const errorMsg = `Failed to copy: ${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(errorMsg);
return formatForInkCli(`${errorMsg}`);
}
},
},
{
name: 'shortcuts',
description: 'Show keyboard shortcuts',
usage: '/shortcuts',
category: 'General',
aliases: ['keys', 'hotkeys'],
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<CommandHandlerResult> => {
const styledData: ShortcutsStyledData = {
categories: [
{
name: 'Global',
shortcuts: [
{ keys: 'Ctrl+C', description: 'Clear input, then exit (press twice)' },
{ keys: 'Ctrl+T', description: 'Toggle task list (show/hide tasks)' },
{ keys: 'Escape', description: 'Cancel processing / close overlay' },
],
},
{
name: 'Input',
shortcuts: [
{ keys: 'Enter', description: 'Submit message' },
{ keys: 'Shift+Enter', description: 'New line (multi-line input)' },
{ keys: 'Up/Down', description: 'Navigate input history' },
{ keys: 'Ctrl+R', description: 'Search history (enter search mode)' },
{ keys: 'Tab', description: 'Autocomplete command' },
{ keys: 'Ctrl+U', description: 'Clear input line' },
{ keys: 'Ctrl+W', description: 'Delete word before cursor' },
{ keys: 'Ctrl+A', description: 'Move cursor to start' },
{ keys: 'Ctrl+E', description: 'Move cursor to end' },
],
},
{
name: 'History Search (after Ctrl+R)',
shortcuts: [
{ keys: 'Ctrl+R', description: 'Next older match' },
{ keys: 'Ctrl+E', description: 'Next newer match' },
{ keys: 'Enter', description: 'Accept and exit search' },
{ keys: 'Escape', description: 'Cancel search' },
],
},
{
name: 'Autocomplete & Selectors',
shortcuts: [
{ keys: 'Up/Down', description: 'Navigate options' },
{ keys: 'Enter', description: 'Select / execute' },
{ keys: 'Tab', description: 'Load command into input' },
{ keys: 'Escape', description: 'Close overlay' },
],
},
{
name: 'Tool Approval',
shortcuts: [
{ keys: 'y', description: 'Allow once' },
{ keys: 'a', description: 'Allow for session' },
{ keys: 'n', description: 'Deny' },
{ keys: 'Escape', description: 'Cancel' },
],
},
],
};
// Build fallback text
const fallbackLines: string[] = ['Keyboard Shortcuts:'];
for (const category of styledData.categories) {
fallbackLines.push(`\n${category.name}:`);
for (const shortcut of category.shortcuts) {
fallbackLines.push(` ${shortcut.keys.padEnd(14)} ${shortcut.description}`);
}
}
return CommandOutputHelper.styled('shortcuts', styledData, fallbackLines.join('\n'));
},
},
];

View File

@@ -0,0 +1,21 @@
/**
* MCP Commands Module
*
* In interactive CLI, /mcp always shows the interactive MCP server list overlay.
* This command definition exists for autocomplete and help display.
*/
import type { CommandDefinition } from '../command-parser.js';
import { overlayOnlyHandler } from '../command-parser.js';
/**
* MCP management command definition.
* Handler is never called - mcp is in ALWAYS_OVERLAY and handled by McpServerList overlay.
*/
export const mcpCommands: CommandDefinition = {
name: 'mcp',
description: 'Manage MCP servers (interactive)',
usage: '/mcp',
category: 'MCP Management',
handler: overlayOnlyHandler,
};

View File

@@ -0,0 +1,22 @@
/**
* Model Commands Module
*
* In interactive CLI, /model always shows the interactive model selector overlay.
* This command definition exists for autocomplete and help display.
*/
import type { CommandDefinition } from '../command-parser.js';
import { overlayOnlyHandler } from '../command-parser.js';
/**
* Model management command definition.
* Handler is never called - model is in ALWAYS_OVERLAY and handled by ModelSelector overlay.
*/
export const modelCommands: CommandDefinition = {
name: 'model',
description: 'Switch AI model (interactive selector)',
usage: '/model',
category: 'General',
aliases: ['m'],
handler: overlayOnlyHandler,
};

View File

@@ -0,0 +1,21 @@
/**
* Plugin Commands Module
*
* In interactive CLI, /plugin always shows the interactive plugin manager overlay.
* This command definition exists for autocomplete and help display.
*/
import type { CommandDefinition } from '../command-parser.js';
import { overlayOnlyHandler } from '../command-parser.js';
/**
* Plugin management command definition.
* Handler is never called - plugin is in ALWAYS_OVERLAY and handled by PluginManager overlay.
*/
export const pluginCommands: CommandDefinition = {
name: 'plugin',
description: 'Manage plugins (interactive)',
usage: '/plugin',
category: 'Plugin Management',
handler: overlayOnlyHandler,
};

View File

@@ -0,0 +1,404 @@
/**
* Prompt Commands Module
*
* This module defines prompt management slash commands for the Dexto CLI interface.
* These commands provide functionality for viewing and managing system prompts.
*
* Available Prompt Commands:
* - /sysprompt - Display the current system prompt
* - /prompts - List all available prompts (MCP + internal)
* - /<prompt-name> [args] - Direct prompt execution (auto-generated for each prompt)
*/
import chalk from 'chalk';
import type { DextoAgent, PromptInfo } from '@dexto/core';
import type { CommandDefinition, CommandContext, CommandHandlerResult } from './command-parser.js';
import { formatForInkCli } from './utils/format-output.js';
import { createSendMessageMarker, type StyledOutput } from '../../ink-cli/services/index.js';
// Avoid depending on core types to keep CLI typecheck independent of build
/**
* Prompt management commands
*/
export const promptCommands: CommandDefinition[] = [
{
name: 'sysprompt',
description: 'Display the current system prompt',
usage: '/sysprompt',
category: 'Prompt Management',
handler: async (
_args: string[],
agent: DextoAgent,
_ctx: CommandContext
): Promise<CommandHandlerResult> => {
try {
const systemPrompt = await agent.getSystemPrompt();
// Return styled output for ink-cli
const styledOutput: StyledOutput = {
styledType: 'sysprompt',
styledData: { content: systemPrompt },
fallbackText: `System Prompt:\n${systemPrompt}`,
};
return styledOutput;
} catch (error) {
const errorMsg = `Failed to get system prompt: ${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(errorMsg);
return formatForInkCli(`${errorMsg}`);
}
},
},
{
name: 'prompts',
description: 'Browse, add, and delete prompts',
usage: '/prompts',
category: 'Prompt Management',
handler: async (
_args: string[],
agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean | string> => {
try {
const prompts = await agent.listPrompts();
const promptNames = Object.keys(prompts || {});
if (promptNames.length === 0) {
const output = '\n⚠ No prompts available';
console.log(chalk.rgb(255, 165, 0)(output));
return formatForInkCli(output);
}
// Build output string
const outputLines: string[] = ['\n📝 Available Prompts:\n'];
// Group by source
const mcpPrompts: string[] = [];
const configPrompts: string[] = [];
const customPrompts: string[] = [];
for (const [name, info] of Object.entries(prompts)) {
if (info.source === 'mcp') {
mcpPrompts.push(name);
} else if (info.source === 'config') {
configPrompts.push(name);
} else if (info.source === 'custom') {
customPrompts.push(name);
}
}
if (mcpPrompts.length > 0) {
outputLines.push('🔗 MCP Prompts:');
mcpPrompts.forEach((name) => {
const info = prompts[name];
if (info) {
const displayName = info.displayName || name;
const title =
info.title && info.title !== displayName ? ` (${info.title})` : '';
const desc = info.description ? ` - ${info.description}` : '';
const args =
info.arguments && info.arguments.length > 0
? ` [args: ${info.arguments
.map((a) => `${a.name}${a.required ? '*' : ''}`)
.join(', ')}]`
: '';
outputLines.push(` ${displayName}${title}${desc}${args}`);
}
});
outputLines.push('');
}
if (configPrompts.length > 0) {
outputLines.push('📋 Config Prompts:');
configPrompts.forEach((name) => {
const info = prompts[name];
if (info) {
const displayName = info.displayName || name;
const title =
info.title && info.title !== displayName ? ` (${info.title})` : '';
const desc = info.description ? ` - ${info.description}` : '';
outputLines.push(` ${displayName}${title}${desc}`);
}
});
outputLines.push('');
}
if (customPrompts.length > 0) {
outputLines.push('✨ Custom Prompts:');
customPrompts.forEach((name) => {
const info = prompts[name];
if (info) {
const displayName = info.displayName || name;
const title =
info.title && info.title !== displayName ? ` (${info.title})` : '';
const desc = info.description ? ` - ${info.description}` : '';
outputLines.push(` ${displayName}${title}${desc}`);
}
});
outputLines.push('');
}
outputLines.push(`Total: ${promptNames.length} prompts`);
const output = outputLines.join('\n');
// Log for regular CLI (with chalk formatting)
console.log(chalk.bold.green('\n📝 Available Prompts:\n'));
if (mcpPrompts.length > 0) {
console.log(chalk.cyan('🔗 MCP Prompts:'));
mcpPrompts.forEach((name) => {
const info = prompts[name];
if (info) {
const displayName = info.displayName || name;
const title =
info.title && info.title !== displayName ? ` (${info.title})` : '';
const desc = info.description ? ` - ${info.description}` : '';
const args =
info.arguments && info.arguments.length > 0
? ` [args: ${info.arguments
.map((a) => `${a.name}${a.required ? '*' : ''}`)
.join(', ')}]`
: '';
console.log(
` ${chalk.blue(displayName)}${chalk.rgb(255, 165, 0)(title)}${chalk.gray(desc)}${chalk.gray(args)}`
);
}
});
console.log();
}
if (configPrompts.length > 0) {
console.log(chalk.cyan('📋 Config Prompts:'));
configPrompts.forEach((name) => {
const info = prompts[name];
if (info) {
const displayName = info.displayName || name;
const title =
info.title && info.title !== displayName ? ` (${info.title})` : '';
const desc = info.description ? ` - ${info.description}` : '';
console.log(
` ${chalk.blue(displayName)}${chalk.rgb(255, 165, 0)(title)}${chalk.gray(desc)}`
);
}
});
console.log();
}
if (customPrompts.length > 0) {
console.log(chalk.greenBright('✨ Custom Prompts:'));
customPrompts.forEach((name) => {
const info = prompts[name];
if (info) {
const displayName = info.displayName || name;
const title =
info.title && info.title !== displayName ? ` (${info.title})` : '';
const desc = info.description ? ` - ${info.description}` : '';
console.log(
` ${chalk.blue(displayName)}${chalk.rgb(255, 165, 0)(title)}${chalk.gray(desc)}`
);
}
});
console.log();
}
console.log(chalk.gray(`Total: ${promptNames.length} prompts`));
console.log(chalk.gray('💡 Use /<prompt-name> to execute a prompt directly\n'));
return formatForInkCli(output);
} catch (error) {
const errorMsg = `Error listing prompts: ${error instanceof Error ? error.message : String(error)}`;
console.error(chalk.red(`${errorMsg}`));
return formatForInkCli(`${errorMsg}`);
}
},
},
// Note: /use command removed - use /<prompt-name> directly instead
// Prompts are automatically registered as slash commands (see getDynamicPromptCommands)
];
/**
* Create a dynamic command definition from a prompt
* @param promptInfo The prompt metadata with pre-computed commandName
*/
function createPromptCommand(promptInfo: PromptInfo): CommandDefinition {
// Use pre-computed commandName (collision-resolved by PromptManager)
// Fall back to displayName or name for backwards compatibility
const commandName = promptInfo.commandName || promptInfo.displayName || promptInfo.name;
// Keep internal name for prompt resolution (e.g., "config:review" or "mcp:server1:review")
const internalName = promptInfo.name;
// Base name for display purposes (without source prefix)
const baseName = promptInfo.displayName || promptInfo.name;
return {
name: commandName,
description: promptInfo.description || `Execute ${baseName} prompt`,
usage: `/${commandName} [context]`,
category: 'Dynamic Prompts',
handler: async (
args: string[],
agent: DextoAgent,
ctx: CommandContext
): Promise<CommandHandlerResult> => {
try {
const { argMap, context: contextString } = splitPromptArguments(args);
// Use resolvePrompt instead of getPrompt + flattenPromptResult (matches WebUI approach)
const resolveOptions: {
args?: Record<string, unknown>;
context?: string;
} = {};
if (Object.keys(argMap).length > 0) {
resolveOptions.args = argMap;
}
if (contextString) {
resolveOptions.context = contextString;
}
// Use internal name for resolution (includes prefix like "config:")
const result = await agent.resolvePrompt(internalName, resolveOptions);
// Apply per-prompt overrides (Phase 2 Claude Code compatibility)
// These overrides persist for the session until explicitly cleared
if (ctx.sessionId) {
// Apply model override if specified
if (result.model) {
console.log(
chalk.gray(`🔄 Switching model to '${result.model}' for this prompt`)
);
try {
await agent.switchLLM({ model: result.model }, ctx.sessionId);
} catch (modelError) {
console.log(
chalk.yellow(
`⚠️ Failed to switch model: ${modelError instanceof Error ? modelError.message : String(modelError)}`
)
);
}
}
// Apply auto-approve tools if specified
// These tools will skip confirmation prompts during skill execution
if (result.allowedTools && result.allowedTools.length > 0) {
console.log(
chalk.gray(`🔓 Auto-approving tools: ${result.allowedTools.join(', ')}`)
);
try {
agent.toolManager.setSessionAutoApproveTools(
ctx.sessionId,
result.allowedTools
);
} catch (toolError) {
console.log(
chalk.yellow(
`⚠️ Failed to set auto-approve tools: ${toolError instanceof Error ? toolError.message : String(toolError)}`
)
);
}
}
}
// Fork skills: route through LLM to call invoke_skill
// This ensures approval flow and context management work naturally through
// processStream, rather than bypassing it with direct tool execution.
if (result.context === 'fork') {
const skillName = internalName;
const taskContext = contextString || '';
// Build instruction message for the LLM
// The <skill-invocation> tags help the LLM recognize this is a structured request
const instructionText = `<skill-invocation>
Execute the fork skill: ${commandName}
${taskContext ? `Task context: ${taskContext}` : ''}
Call the internal--invoke_skill tool immediately with:
- skill: "${skillName}"
${taskContext ? `- taskContext: "${taskContext}"` : ''}
</skill-invocation>`;
return createSendMessageMarker(instructionText);
}
// Inline skills: wrap content in <skill-invocation> for clean history display
// Convert resource URIs to @resource mentions so agent.run() can expand them
let finalText = result.text;
if (result.resources.length > 0) {
// Append resource references as @<uri> format
const resourceRefs = result.resources.map((uri) => `@<${uri}>`).join(' ');
finalText = finalText ? `${finalText}\n\n${resourceRefs}` : resourceRefs;
}
if (finalText.trim()) {
// Wrap in <skill-invocation> tags for clean display in history
// The tags help formatSkillInvocationMessage() detect and format these
const taskContext = contextString || '';
const wrappedText = `<skill-invocation>
Execute the inline skill: ${commandName}
${taskContext ? `Task context: ${taskContext}` : ''}
skill: "${internalName}"
</skill-invocation>
${finalText.trim()}`;
return createSendMessageMarker(wrappedText);
} else {
const warningMsg = `⚠️ Prompt '${commandName}' returned no content`;
console.log(chalk.rgb(255, 165, 0)(warningMsg));
return formatForInkCli(warningMsg);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
agent.logger.error(
`Failed to execute prompt command '${commandName}': ${errorMessage}`
);
const errorMsg = `❌ Error executing prompt '${commandName}': ${errorMessage}`;
return formatForInkCli(errorMsg);
}
},
};
}
/**
* Get all dynamic prompt commands based on available prompts.
* Uses pre-computed commandName from PromptManager for collision handling.
* Filters out prompts with `userInvocable: false` as these are not intended
* for direct user invocation via slash commands.
*/
export async function getDynamicPromptCommands(agent: DextoAgent): Promise<CommandDefinition[]> {
try {
const prompts = await agent.listPrompts();
// Filter out prompts that are not user-invocable (userInvocable: false)
// These prompts are intended for LLM auto-invocation only, not CLI slash commands
const promptEntries = Object.entries(prompts).filter(
([, info]) => info.userInvocable !== false
);
// Create commands using pre-computed commandName (collision-resolved by PromptManager)
return promptEntries.map(([, info]) => createPromptCommand(info));
} catch (error) {
agent.logger.error(
`Failed to get dynamic prompt commands: ${error instanceof Error ? error.message : String(error)}`
);
return [];
}
}
function splitPromptArguments(args: string[]): {
argMap: Record<string, string>;
context?: string | undefined;
} {
const map: Record<string, string> = {};
const contextParts: string[] = [];
for (const arg of args) {
const equalsIndex = arg.indexOf('=');
if (equalsIndex > 0) {
const key = arg.slice(0, equalsIndex).trim();
const value = arg.slice(equalsIndex + 1);
if (key.length > 0) {
map[key] = value;
}
} else if (arg.trim().length > 0) {
contextParts.push(arg);
}
}
const context = contextParts.length > 0 ? contextParts.join(' ') : undefined;
return { argMap: map, context };
}

View File

@@ -0,0 +1,15 @@
/**
* Session Commands Module (Interactive CLI)
*
* This module provides session-related commands for the interactive CLI.
* These commands use interactive overlays/selectors rather than subcommands.
*
* Exports:
* - searchCommand: Opens interactive search overlay
* - resumeCommand: Shows interactive session selector
* - renameCommand: Rename the current session
*
* Note: For headless CLI session management, see src/cli/commands/session-commands.ts
*/
export { searchCommand, resumeCommand, renameCommand } from './session-commands.js';

View File

@@ -0,0 +1,86 @@
/**
* Session Management Commands (Interactive CLI)
*
* This module contains session-related commands for the interactive CLI.
* Session management in interactive mode uses overlays/selectors rather than subcommands.
*
* Commands:
* - resume: Shows interactive session selector
* - search: Opens interactive search overlay
* - rename: Rename the current session
*
* Note: For headless CLI session management (list, history, delete),
* see src/cli/commands/session-commands.ts
*/
import chalk from 'chalk';
import type { DextoAgent } from '@dexto/core';
import type { CommandDefinition, CommandContext } from '../command-parser.js';
/**
* Resume command - shows interactive session selector
* Note: In interactive CLI, this always shows the selector (args ignored)
* For headless CLI, use `dexto -r <sessionId>` instead
*/
export const resumeCommand: CommandDefinition = {
name: 'resume',
description: 'Switch to a different session (interactive selector)',
usage: '/resume',
category: 'General',
aliases: ['r'],
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean | string> => {
// In interactive CLI, /resume always triggers the interactive selector
// The selector is shown via detectInteractiveSelector in inputParsing.ts
// This handler should not be called in ink-cli (selector shows instead)
const helpText = [
'📋 Resume Session',
'\nType /resume to show the session selector\n',
].join('\n');
console.log(chalk.blue(helpText));
return helpText;
},
};
/**
* Standalone search command - opens interactive search overlay
*/
export const searchCommand: CommandDefinition = {
name: 'search',
description: 'Search messages across all sessions',
usage: '/search',
category: 'General',
aliases: ['find'],
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean> => {
// Interactive overlay handles everything - just return success
return true;
},
};
/**
* Rename command - rename the current session
* In interactive CLI, this shows the rename overlay.
* The overlay is triggered via commandOverlays.ts registry.
*/
export const renameCommand: CommandDefinition = {
name: 'rename',
description: 'Rename the current session',
usage: '/rename',
category: 'General',
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean> => {
// Interactive overlay handles everything - just return success
return true;
},
};

View File

@@ -0,0 +1,13 @@
/**
* System Commands Module Index
*
* This module exports all system-level CLI commands for the Dexto interactive interface.
* System commands provide functionality for configuration, logging, and statistics.
*
* Available commands:
* - /log - Set or view log level
* - /config - Show current configuration
* - /stats - Show system statistics
*/
export { systemCommands } from './system-commands.js';

View File

@@ -0,0 +1,256 @@
/**
* System Commands Module
*
* This module defines system-level slash commands for the Dexto CLI interface.
* These commands provide system configuration, logging, and statistics functionality.
*
* Available System Commands:
* - /log [level] - Set or view log level
* - /config - Show current configuration
* - /stats - Show system statistics
* - /stream - Toggle streaming mode for LLM responses
*/
import chalk from 'chalk';
import { logger, type DextoAgent } from '@dexto/core';
import type { CommandDefinition, CommandHandlerResult, CommandContext } from '../command-parser.js';
import { formatForInkCli } from '../utils/format-output.js';
import { CommandOutputHelper } from '../utils/command-output.js';
import type { ConfigStyledData, StatsStyledData } from '../../../ink-cli/state/types.js';
/**
* System commands for configuration and monitoring
*/
export const systemCommands: CommandDefinition[] = [
{
name: 'log',
description: 'View or change log level interactively',
usage: '/log [level]',
category: 'System',
aliases: [],
handler: async (
args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean | string> => {
const validLevels = ['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly'];
const level = args[0];
if (!level) {
// Interactive view: show current level and options
const currentLevel = logger.getLevel();
const logFilePath = logger.getLogFilePath();
console.log(chalk.bold.blue('\n📊 Logging Configuration:\n'));
console.log(` Current level: ${chalk.green.bold(currentLevel)}`);
if (logFilePath) {
console.log(` Log file: ${chalk.cyan(logFilePath)}`);
}
console.log(chalk.gray('\n Available levels (from least to most verbose):'));
validLevels.forEach((lvl) => {
const isCurrent = lvl === currentLevel;
const marker = isCurrent ? chalk.green('▶') : ' ';
const levelText = isCurrent ? chalk.green.bold(lvl) : chalk.gray(lvl);
console.log(` ${marker} ${levelText}`);
});
console.log(
chalk.gray('\n 💡 Use /log <level> to change level (e.g., /log debug)\n')
);
const output = [
'\n📊 Logging Configuration:',
`Current level: ${currentLevel}`,
logFilePath ? `Log file: ${logFilePath}` : '',
'\nAvailable levels: error, warn, info, http, verbose, debug, silly',
'💡 Use /log <level> to change level',
]
.filter(Boolean)
.join('\n');
return formatForInkCli(output);
}
if (validLevels.includes(level)) {
logger.setLevel(level);
logger.info(`Log level set to ${level}`, null, 'green');
console.log(chalk.green(`✅ Log level changed to: ${chalk.bold(level)}`));
const output = `✅ Log level set to ${level}`;
return formatForInkCli(output);
} else {
const errorMsg = `❌ Invalid log level: ${level}\nValid levels: ${validLevels.join(', ')}`;
console.log(chalk.red(`❌ Invalid log level: ${chalk.bold(level)}`));
console.log(chalk.gray(`Valid levels: ${validLevels.join(', ')}`));
return formatForInkCli(errorMsg);
}
},
},
{
name: 'config',
description: 'Show current configuration',
usage: '/config',
category: 'System',
handler: async (
_args: string[],
agent: DextoAgent,
_ctx: CommandContext
): Promise<CommandHandlerResult> => {
try {
const config = agent.getEffectiveConfig();
const servers = Object.keys(config.mcpServers || {});
// Get config file path (may not exist for programmatic agents)
let configFilePath: string | null = null;
try {
configFilePath = agent.getAgentFilePath();
} catch {
// No config file path available
}
// Get enabled plugins
const pluginsEnabled: string[] = [];
if (config.plugins) {
// Check built-in plugins
if (config.plugins.contentPolicy?.enabled) {
pluginsEnabled.push('contentPolicy');
}
if (config.plugins.responseSanitizer?.enabled) {
pluginsEnabled.push('responseSanitizer');
}
// Check custom plugins
for (const plugin of config.plugins.custom || []) {
if (plugin.enabled) {
pluginsEnabled.push(plugin.name);
}
}
}
// Build styled data
const styledData: ConfigStyledData = {
configFilePath,
provider: config.llm.provider,
model: config.llm.model,
maxTokens: config.llm.maxOutputTokens ?? null,
temperature: config.llm.temperature ?? null,
toolConfirmationMode: config.toolConfirmation?.mode || 'auto',
maxSessions: config.sessions?.maxSessions?.toString() || 'Default',
sessionTTL: config.sessions?.sessionTTL
? `${config.sessions.sessionTTL / 1000}s`
: 'Default',
mcpServers: servers,
promptsCount: config.prompts?.length || 0,
pluginsEnabled,
};
// Build fallback text (no console.log - interferes with Ink rendering)
const fallbackLines: string[] = [
'Configuration:',
configFilePath ? ` Config: ${configFilePath}` : '',
` LLM: ${config.llm.provider} / ${config.llm.model}`,
` Tool Confirmation: ${styledData.toolConfirmationMode}`,
` Sessions: max=${styledData.maxSessions}, ttl=${styledData.sessionTTL}`,
` MCP Servers: ${servers.length > 0 ? servers.join(', ') : 'none'}`,
` Prompts: ${styledData.promptsCount}`,
].filter(Boolean);
return CommandOutputHelper.styled('config', styledData, fallbackLines.join('\n'));
} catch (error) {
const errorMsg = `Failed to get configuration: ${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(errorMsg);
return formatForInkCli(`${errorMsg}`);
}
},
},
{
name: 'stats',
description: 'Show system statistics',
usage: '/stats',
category: 'System',
handler: async (
_args: string[],
agent: DextoAgent,
ctx: CommandContext
): Promise<CommandHandlerResult> => {
try {
// Session stats
const sessionStats = await agent.sessionManager.getSessionStats();
// MCP stats
const connectedServers = agent.getMcpClients().size;
const failedConnections = Object.keys(agent.getMcpFailedConnections()).length;
// Tools
let toolCount = 0;
try {
const tools = await agent.getAllMcpTools();
toolCount = Object.keys(tools).length;
} catch {
// Ignore - toolCount stays 0
}
// Get token usage from current session metadata
let tokenUsage: StatsStyledData['tokenUsage'];
let estimatedCost: number | undefined;
if (ctx.sessionId) {
const sessionMetadata = await agent.sessionManager.getSessionMetadata(
ctx.sessionId
);
if (sessionMetadata?.tokenUsage) {
tokenUsage = sessionMetadata.tokenUsage;
}
estimatedCost = sessionMetadata?.estimatedCost;
}
// Build styled data
const styledData: StatsStyledData = {
sessions: {
total: sessionStats.totalSessions,
inMemory: sessionStats.inMemorySessions,
maxAllowed: sessionStats.maxSessions,
},
mcp: {
connected: connectedServers,
failed: failedConnections,
toolCount,
},
...(tokenUsage && { tokenUsage }),
...(estimatedCost !== undefined && { estimatedCost }),
};
// Build fallback text
const fallbackLines: string[] = [
'System Statistics:',
` Sessions: ${sessionStats.totalSessions} total, ${sessionStats.inMemorySessions} in memory`,
` MCP: ${connectedServers} connected, ${toolCount} tools`,
];
if (failedConnections > 0) {
fallbackLines.push(` Failed connections: ${failedConnections}`);
}
if (tokenUsage) {
fallbackLines.push(
` Tokens: ${tokenUsage.totalTokens} total (${tokenUsage.inputTokens} in, ${tokenUsage.outputTokens} out)`
);
}
return CommandOutputHelper.styled('stats', styledData, fallbackLines.join('\n'));
} catch (error) {
const errorMsg = `Failed to get statistics: ${error instanceof Error ? error.message : String(error)}`;
agent.logger.error(errorMsg);
return formatForInkCli(`${errorMsg}`);
}
},
},
{
name: 'stream',
description: 'Toggle streaming mode for LLM responses',
usage: '/stream',
category: 'System',
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean | string> => {
// Overlay is handled via commandOverlays.ts mapping
return true;
},
},
];

View File

@@ -0,0 +1,32 @@
/**
* Tool Commands Module
*
* This module defines tool management slash commands for the Dexto CLI interface.
* These commands provide functionality for listing and managing MCP tools.
*
* Available Tool Commands:
* - /tools - Interactive tool browser
*/
import type { DextoAgent } from '@dexto/core';
import type { CommandDefinition, CommandContext } from './command-parser.js';
/**
* Tool management commands
*/
export const toolCommands: CommandDefinition[] = [
{
name: 'tools',
description: 'Browse available tools interactively',
usage: '/tools',
category: 'Tool Management',
handler: async (
_args: string[],
_agent: DextoAgent,
_ctx: CommandContext
): Promise<boolean | string> => {
// Overlay is handled via commandOverlays.ts mapping
return true;
},
},
];

View File

@@ -0,0 +1,98 @@
/**
* Argument Parsing Utilities
*
* This module provides utilities for parsing command-line arguments in the
* interactive CLI commands. These utilities are designed to be reusable
* across different command modules.
*/
/**
* Result of parsing command-line arguments
*/
export interface ParsedArguments {
/** Regular arguments (non-option arguments) */
parsedArgs: string[];
/** Options in --key=value format */
options: Record<string, string>;
/** Flags in --flag format (boolean options) */
flags: Set<string>;
}
/**
* Parse command-line arguments supporting --key=value options and --flag style flags.
*
* This function separates regular arguments from options and flags:
* - Regular args: any argument that doesn't start with --
* - Options: --key=value format, parsed into options object
* - Flags: --flag format (no value), parsed into flags set
*
* @param args Array of command-line arguments to parse
* @returns Object containing parsed arguments, options, and flags
*
* @example
* ```typescript
* const result = parseOptions(['server', 'cmd', '--timeout=5000', '--verbose']);
* // result.parsedArgs: ['server', 'cmd']
* // result.options: { timeout: '5000' }
* // result.flags: Set(['verbose'])
* ```
*/
export function parseOptions(args: string[]): ParsedArguments {
const parsedArgs: string[] = [];
const options: Record<string, string> = {};
const flags: Set<string> = new Set();
for (const arg of args) {
if (arg.startsWith('--')) {
if (arg.includes('=')) {
// Handle --key=value format
const [key, ...valueParts] = arg.slice(2).split('=');
if (key) {
// Rejoin value parts in case the value contained '=' characters
options[key] = valueParts.join('=');
}
} else {
// Handle --flag format (boolean flags)
flags.add(arg.slice(2));
}
} else {
// Regular argument (not an option)
parsedArgs.push(arg);
}
}
return { parsedArgs, options, flags };
}
/**
* Convert parsed options back to command-line argument format.
* Useful for debugging or reconstructing command lines.
*
* @param parsed The parsed arguments object
* @returns Array of command-line arguments
*
* @example
* ```typescript
* const args = reconstructArgs({
* parsedArgs: ['server', 'cmd'],
* options: { timeout: '5000' },
* flags: new Set(['verbose'])
* });
* // Result: ['server', 'cmd', '--timeout=5000', '--verbose']
* ```
*/
export function reconstructArgs(parsed: ParsedArguments): string[] {
const result: string[] = [...parsed.parsedArgs];
// Add options in --key=value format
for (const [key, value] of Object.entries(parsed.options)) {
result.push(`--${key}=${value}`);
}
// Add flags in --flag format
for (const flag of parsed.flags) {
result.push(`--${flag}`);
}
return result;
}

View File

@@ -0,0 +1,69 @@
/**
* Command Output Helper
* Utilities for consistent command output handling across all slash commands
*/
import { formatForInkCli } from './format-output.js';
import type { StyledOutput } from '../../../ink-cli/services/CommandService.js';
import type { StyledMessageType, StyledData } from '../../../ink-cli/state/types.js';
/**
* Command output helper for consistent display and error handling
* Returns formatted strings for ink-cli to render (no direct console output)
*/
export class CommandOutputHelper {
/**
* Format success message for ink-cli to display
*/
static success(message: string): string {
return formatForInkCli(message);
}
/**
* Format info message for ink-cli to display
*/
static info(message: string): string {
return formatForInkCli(message);
}
/**
* Format warning message for ink-cli to display
*/
static warning(message: string): string {
return formatForInkCli(`⚠️ ${message}`);
}
/**
* Format error message for ink-cli to display
*/
static error(error: unknown, context?: string): string {
const errorMessage = error instanceof Error ? error.message : String(error);
const fullMessage = context ? `${context}: ${errorMessage}` : `${errorMessage}`;
return formatForInkCli(fullMessage);
}
/**
* Format multi-line output for ink-cli to display
*/
static output(lines: string[]): string {
return formatForInkCli(lines.join('\n'));
}
/**
* Create styled output for rich rendering in ink-cli
* @param styledType - The type of styled rendering
* @param styledData - The structured data for rendering
* @param fallbackText - Plain text fallback for logging/non-ink environments
*/
static styled(
styledType: StyledMessageType,
styledData: StyledData,
fallbackText: string
): StyledOutput {
return {
styledType,
styledData,
fallbackText: formatForInkCli(fallbackText),
};
}
}

View File

@@ -0,0 +1,21 @@
/**
* Utility functions for formatting command output for ink-cli
* Strips chalk formatting and provides plain text versions
*/
import { stripVTControlCharacters } from 'node:util';
/**
* Strip ANSI color codes from a string
*/
export function stripAnsi(str: string): string {
return stripVTControlCharacters(str);
}
/**
* Format output for ink-cli (removes chalk formatting)
* This allows commands to use chalk for regular CLI while returning plain text for ink-cli
*/
export function formatForInkCli(output: string): string {
return stripAnsi(output);
}

View File

@@ -0,0 +1,288 @@
// packages/cli/src/cli/commands/list-agents.ts
import { existsSync } from 'fs';
import { promises as fs } from 'fs';
import path from 'path';
import chalk from 'chalk';
import { z } from 'zod';
import {
getDextoGlobalPath,
globalPreferencesExist,
loadGlobalPreferences,
loadBundledRegistryAgents,
} from '@dexto/agent-management';
// Zod schema for list-agents command validation
const ListAgentsCommandSchema = z
.object({
verbose: z.boolean().default(false),
installed: z.boolean().default(false),
available: z.boolean().default(false),
})
.strict();
export type ListAgentsCommandOptions = z.output<typeof ListAgentsCommandSchema>;
export type ListAgentsCommandOptionsInput = z.input<typeof ListAgentsCommandSchema>;
/**
* Information about an installed agent
*/
interface InstalledAgentInfo {
name: string;
description: string;
path: string;
llmProvider?: string;
llmModel?: string;
installedAt?: Date;
}
/**
* Information about an available agent from registry
*/
interface AvailableAgentInfo {
name: string;
description: string;
author: string;
tags: string[];
type: 'builtin' | 'custom';
}
/**
* Get information about installed agents
*/
async function getInstalledAgents(): Promise<InstalledAgentInfo[]> {
const globalAgentsDir = getDextoGlobalPath('agents');
if (!existsSync(globalAgentsDir)) {
return [];
}
const bundledRegistry = loadBundledRegistryAgents();
const installedAgents: InstalledAgentInfo[] = [];
try {
const entries = await fs.readdir(globalAgentsDir, { withFileTypes: true });
for (const entry of entries) {
// Skip registry.json and temp files
if (entry.name === 'registry.json' || entry.name.includes('.tmp.')) {
continue;
}
if (entry.isDirectory()) {
const agentName = entry.name;
const agentPath = path.join(globalAgentsDir, entry.name);
try {
// For directory agents, try to find main config
// Check bundled registry for main field, default to agent.yml
const bundledEntry = bundledRegistry[agentName];
const mainFile = bundledEntry?.main || 'agent.yml';
const mainConfigPath = path.join(agentPath, mainFile);
// If main config doesn't exist, skip
if (!existsSync(mainConfigPath)) {
console.warn(
`Warning: Could not find main config for agent '${agentName}' at ${mainConfigPath}`
);
continue;
}
// Get install date from directory stats
const stats = await fs.stat(agentPath);
// Try to extract LLM info from config
let llmProvider: string | undefined;
let llmModel: string | undefined;
try {
const configContent = await fs.readFile(mainConfigPath, 'utf-8');
const configMatch = configContent.match(/provider:\s*([^\n\r]+)/);
const modelMatch = configContent.match(/model:\s*([^\n\r]+)/);
llmProvider = configMatch?.[1]?.trim();
llmModel = modelMatch?.[1]?.trim();
} catch (_error) {
// Ignore config parsing errors
}
// Get description from bundled registry if available
const description = bundledEntry?.description || 'Custom agent';
const agentInfo: InstalledAgentInfo = {
name: agentName,
description,
path: mainConfigPath,
installedAt: stats.birthtime || stats.mtime,
};
if (llmProvider) agentInfo.llmProvider = llmProvider;
if (llmModel) agentInfo.llmModel = llmModel;
installedAgents.push(agentInfo);
} catch (error) {
// Skip agents that can't be processed
console.warn(`Warning: Could not process agent '${agentName}': ${error}`);
}
}
}
} catch (_error) {
// Return empty array if we can't read the directory
return [];
}
return installedAgents.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Get information about available agents from registry
*/
function getAvailableAgents(): AvailableAgentInfo[] {
const bundledRegistry = loadBundledRegistryAgents();
return Object.entries(bundledRegistry)
.map(([name, data]) => ({
name,
description: data.description || 'No description',
author: data.author || 'Unknown',
tags: data.tags || [],
type: 'builtin' as const,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Handle the list-agents command
*/
export async function handleListAgentsCommand(
options: ListAgentsCommandOptionsInput
): Promise<void> {
// Validate command with Zod
const validated = ListAgentsCommandSchema.parse(options);
console.log(chalk.cyan('\n📋 Dexto Agents\n'));
// Get global preferences for LLM info
let globalLLM: string | undefined;
if (globalPreferencesExist()) {
try {
const preferences = await loadGlobalPreferences();
globalLLM = `${preferences.llm.provider}/${preferences.llm.model}`;
} catch {
// Ignore preference loading errors
}
}
// Get installed and available agents
const installedAgents = await getInstalledAgents();
const availableAgents = getAvailableAgents();
// Filter based on options
const showInstalled = !validated.available || validated.installed;
const showAvailable = !validated.installed || validated.available;
// Show installed agents
if (showInstalled && installedAgents.length > 0) {
console.log(chalk.green('✅ Installed Agents:'));
for (const agent of installedAgents) {
const llmInfo =
agent.llmProvider && agent.llmModel
? `${agent.llmProvider}/${agent.llmModel}`
: globalLLM || 'Unknown LLM';
const llmDisplay = chalk.gray(`(${llmInfo})`);
if (validated.verbose) {
console.log(` ${chalk.bold(agent.name)} ${llmDisplay}`);
console.log(` ${chalk.gray(agent.description)}`);
console.log(` ${chalk.gray('Path:')} ${agent.path}`);
if (agent.installedAt) {
console.log(
` ${chalk.gray('Installed:')} ${agent.installedAt.toLocaleDateString()}`
);
}
console.log();
} else {
console.log(`${chalk.bold(agent.name)} ${llmDisplay} - ${agent.description}`);
}
}
console.log();
} else if (showInstalled) {
console.log(chalk.rgb(255, 165, 0)('📦 No agents installed yet.'));
console.log(
chalk.gray(' Use `dexto install <agent-name>` to install agents from the registry.\n')
);
}
// Show available agents (not installed)
if (showAvailable) {
const availableNotInstalled = availableAgents.filter(
(available) => !installedAgents.some((installed) => installed.name === available.name)
);
const builtinAgents = availableNotInstalled.filter((a) => a.type === 'builtin');
const customAgents = availableNotInstalled.filter((a) => a.type === 'custom');
if (builtinAgents.length > 0) {
console.log(chalk.blue('📋 Builtin Agents Available to Install:'));
for (const agent of builtinAgents) {
if (validated.verbose) {
console.log(` ${chalk.bold(agent.name)}`);
console.log(` ${chalk.gray(agent.description)}`);
console.log(` ${chalk.gray('Author:')} ${agent.author}`);
console.log(` ${chalk.gray('Tags:')} ${agent.tags.join(', ')}`);
console.log();
} else {
console.log(`${chalk.bold(agent.name)} - ${agent.description}`);
}
}
console.log();
}
if (customAgents.length > 0) {
console.log(chalk.cyan('🔧 Custom Agents Available:'));
for (const agent of customAgents) {
if (validated.verbose) {
console.log(` ${chalk.bold(agent.name)}`);
console.log(` ${chalk.gray(agent.description)}`);
console.log(` ${chalk.gray('Author:')} ${agent.author}`);
console.log(` ${chalk.gray('Tags:')} ${agent.tags.join(', ')}`);
console.log();
} else {
console.log(`${chalk.bold(agent.name)} - ${agent.description}`);
}
}
console.log();
}
}
// Show summary
const totalInstalled = installedAgents.length;
const availableToInstall = availableAgents.filter(
(a) => !installedAgents.some((i) => i.name === a.name)
).length;
if (!validated.verbose) {
console.log(
chalk.gray(
`📊 Summary: ${totalInstalled} installed, ${availableToInstall} available to install`
)
);
if (availableToInstall > 0) {
console.log(
chalk.gray(` Use \`dexto install <agent-name>\` to install more agents.`)
);
}
console.log(chalk.gray(` Use \`dexto list-agents --verbose\` for detailed information.`));
console.log(
chalk.gray(` After installing an agent, use \`dexto -a <agent-name>\` to run it.`)
);
}
console.log();
}

View File

@@ -0,0 +1,518 @@
/**
* Plugin CLI Command Handlers
*
* Handles CLI commands for plugin management:
* - dexto plugin list
* - dexto plugin install --path <path>
* - dexto plugin uninstall <name>
* - dexto plugin validate [path]
*/
import { z } from 'zod';
import chalk from 'chalk';
import {
listInstalledPlugins,
installPluginFromPath,
uninstallPlugin,
validatePluginDirectory,
type PluginInstallScope,
// Marketplace
addMarketplace,
removeMarketplace,
updateMarketplace,
listMarketplaces,
listAllMarketplacePlugins,
installPluginFromMarketplace,
} from '@dexto/agent-management';
// === Schema Definitions ===
const PluginListCommandSchema = z
.object({
verbose: z.boolean().default(false).describe('Show detailed plugin information'),
})
.strict();
const PluginInstallCommandSchema = z
.object({
path: z.string().min(1).describe('Path to the plugin directory'),
scope: z.enum(['user', 'project', 'local']).default('user').describe('Installation scope'),
force: z.boolean().default(false).describe('Force overwrite if already installed'),
})
.strict();
const PluginUninstallCommandSchema = z
.object({
name: z.string().min(1).describe('Name of the plugin to uninstall'),
})
.strict();
const PluginValidateCommandSchema = z
.object({
path: z.string().default('.').describe('Path to the plugin directory to validate'),
})
.strict();
// === Marketplace Command Schemas ===
const MarketplaceAddCommandSchema = z
.object({
source: z
.string()
.min(1)
.describe('Marketplace source (owner/repo, git URL, or local path)'),
name: z.string().optional().describe('Custom name for the marketplace'),
})
.strict();
const MarketplaceRemoveCommandSchema = z
.object({
name: z.string().min(1).describe('Name of the marketplace to remove'),
})
.strict();
const MarketplaceUpdateCommandSchema = z
.object({
name: z.string().optional().describe('Name of the marketplace to update (all if omitted)'),
})
.strict();
const MarketplaceListCommandSchema = z
.object({
verbose: z.boolean().default(false).describe('Show detailed marketplace information'),
})
.strict();
const MarketplaceInstallCommandSchema = z
.object({
plugin: z.string().min(1).describe('Plugin spec: name or name@marketplace'),
scope: z.enum(['user', 'project', 'local']).default('user').describe('Installation scope'),
force: z.boolean().default(false).describe('Force reinstall if already exists'),
})
.strict();
// === Type Exports ===
export type PluginListCommandOptions = z.output<typeof PluginListCommandSchema>;
export type PluginListCommandOptionsInput = z.input<typeof PluginListCommandSchema>;
export type PluginInstallCommandOptions = z.output<typeof PluginInstallCommandSchema>;
export type PluginInstallCommandOptionsInput = z.input<typeof PluginInstallCommandSchema>;
export type PluginUninstallCommandOptions = z.output<typeof PluginUninstallCommandSchema>;
export type PluginUninstallCommandOptionsInput = z.input<typeof PluginUninstallCommandSchema>;
export type PluginValidateCommandOptions = z.output<typeof PluginValidateCommandSchema>;
export type PluginValidateCommandOptionsInput = z.input<typeof PluginValidateCommandSchema>;
// Marketplace command types
export type MarketplaceAddCommandOptions = z.output<typeof MarketplaceAddCommandSchema>;
export type MarketplaceAddCommandOptionsInput = z.input<typeof MarketplaceAddCommandSchema>;
export type MarketplaceRemoveCommandOptions = z.output<typeof MarketplaceRemoveCommandSchema>;
export type MarketplaceRemoveCommandOptionsInput = z.input<typeof MarketplaceRemoveCommandSchema>;
export type MarketplaceUpdateCommandOptions = z.output<typeof MarketplaceUpdateCommandSchema>;
export type MarketplaceUpdateCommandOptionsInput = z.input<typeof MarketplaceUpdateCommandSchema>;
export type MarketplaceListCommandOptions = z.output<typeof MarketplaceListCommandSchema>;
export type MarketplaceListCommandOptionsInput = z.input<typeof MarketplaceListCommandSchema>;
export type MarketplaceInstallCommandOptions = z.output<typeof MarketplaceInstallCommandSchema>;
export type MarketplaceInstallCommandOptionsInput = z.input<typeof MarketplaceInstallCommandSchema>;
// === Command Handlers ===
/**
* Handles the `dexto plugin list` command.
* Lists all installed plugins managed by Dexto.
*/
export async function handlePluginListCommand(
options: PluginListCommandOptionsInput
): Promise<void> {
const validated = PluginListCommandSchema.parse(options);
const plugins = listInstalledPlugins();
if (plugins.length === 0) {
console.log(chalk.yellow('No plugins installed.'));
console.log('');
console.log('Install a plugin with:');
console.log(chalk.cyan(' dexto plugin install --path <path-to-plugin>'));
return;
}
console.log(chalk.bold(`Installed Plugins (${plugins.length}):`));
console.log('');
for (const plugin of plugins) {
const sourceLabel = getSourceLabel(plugin.source);
const scopeLabel = plugin.scope ? ` [${plugin.scope}]` : '';
console.log(
` ${chalk.green(plugin.name)}${chalk.dim('@' + (plugin.version || 'unknown'))} ${sourceLabel}${scopeLabel}`
);
if (validated.verbose) {
if (plugin.description) {
console.log(chalk.dim(` ${plugin.description}`));
}
console.log(chalk.dim(` Path: ${plugin.path}`));
if (plugin.installedAt) {
const date = new Date(plugin.installedAt).toLocaleDateString();
console.log(chalk.dim(` Installed: ${date}`));
}
}
}
console.log('');
}
/**
* Handles the `dexto plugin install --path <path>` command.
* Installs a plugin from a local directory.
*/
export async function handlePluginInstallCommand(
options: PluginInstallCommandOptionsInput
): Promise<void> {
const validated = PluginInstallCommandSchema.parse(options);
console.log(chalk.cyan(`Installing plugin from ${validated.path}...`));
console.log('');
const result = await installPluginFromPath(validated.path, {
scope: validated.scope as PluginInstallScope,
force: validated.force,
});
// Show warnings
if (result.warnings.length > 0) {
console.log(chalk.yellow('Warnings:'));
for (const warning of result.warnings) {
console.log(chalk.yellow(` - ${warning}`));
}
console.log('');
}
console.log(chalk.green(`Successfully installed plugin '${result.pluginName}'`));
console.log(chalk.dim(` Path: ${result.installPath}`));
console.log('');
}
/**
* Handles the `dexto plugin uninstall <name>` command.
* Uninstalls a plugin by name.
*/
export async function handlePluginUninstallCommand(
options: PluginUninstallCommandOptionsInput
): Promise<void> {
const validated = PluginUninstallCommandSchema.parse(options);
console.log(chalk.cyan(`Uninstalling plugin '${validated.name}'...`));
const result = await uninstallPlugin(validated.name);
console.log(chalk.green(`Successfully uninstalled plugin '${validated.name}'`));
if (result.removedPath) {
console.log(chalk.dim(` Removed: ${result.removedPath}`));
}
console.log('');
}
/**
* Handles the `dexto plugin validate [path]` command.
* Validates a plugin directory structure and manifest.
*/
export async function handlePluginValidateCommand(
options: PluginValidateCommandOptionsInput
): Promise<void> {
const validated = PluginValidateCommandSchema.parse(options);
console.log(chalk.cyan(`Validating plugin at ${validated.path}...`));
console.log('');
const result = validatePluginDirectory(validated.path);
if (result.valid) {
console.log(chalk.green('Plugin is valid!'));
console.log('');
if (result.manifest) {
console.log(chalk.bold('Manifest:'));
console.log(` Name: ${chalk.green(result.manifest.name)}`);
if (result.manifest.description) {
console.log(` Description: ${result.manifest.description}`);
}
if (result.manifest.version) {
console.log(` Version: ${result.manifest.version}`);
}
console.log('');
}
} else {
console.log(chalk.red('Plugin validation failed!'));
console.log('');
}
// Show errors
if (result.errors.length > 0) {
console.log(chalk.red('Errors:'));
for (const error of result.errors) {
console.log(chalk.red(` - ${error}`));
}
console.log('');
}
// Show warnings
if (result.warnings.length > 0) {
console.log(chalk.yellow('Warnings:'));
for (const warning of result.warnings) {
console.log(chalk.yellow(` - ${warning}`));
}
console.log('');
}
// Exit with error code if invalid
if (!result.valid) {
const errorDetails = result.errors.length > 0 ? `: ${result.errors.join(', ')}` : '';
throw new Error(`Plugin validation failed${errorDetails}`);
}
}
// === Marketplace Command Handlers ===
/**
* Handles the `dexto plugin marketplace add <source>` command.
* Adds a new marketplace from GitHub, git URL, or local path.
*/
export async function handleMarketplaceAddCommand(
options: MarketplaceAddCommandOptionsInput
): Promise<void> {
const validated = MarketplaceAddCommandSchema.parse(options);
console.log(chalk.cyan(`Adding marketplace from ${validated.source}...`));
console.log('');
const result = await addMarketplace(validated.source, {
name: validated.name,
});
// Show warnings
if (result.warnings.length > 0) {
console.log(chalk.yellow('Warnings:'));
for (const warning of result.warnings) {
console.log(chalk.yellow(` - ${warning}`));
}
console.log('');
}
console.log(chalk.green(`Successfully added marketplace '${result.name}'`));
console.log(chalk.dim(` Plugins found: ${result.pluginCount}`));
console.log('');
}
/**
* Handles the `dexto plugin marketplace remove <name>` command.
* Removes a registered marketplace.
*/
export async function handleMarketplaceRemoveCommand(
options: MarketplaceRemoveCommandOptionsInput
): Promise<void> {
const validated = MarketplaceRemoveCommandSchema.parse(options);
console.log(chalk.cyan(`Removing marketplace '${validated.name}'...`));
await removeMarketplace(validated.name);
console.log(chalk.green(`Successfully removed marketplace '${validated.name}'`));
console.log('');
}
/**
* Handles the `dexto plugin marketplace update [name]` command.
* Updates marketplace(s) by pulling latest from git.
*/
export async function handleMarketplaceUpdateCommand(
options: MarketplaceUpdateCommandOptionsInput
): Promise<void> {
const validated = MarketplaceUpdateCommandSchema.parse(options);
if (validated.name) {
console.log(chalk.cyan(`Updating marketplace '${validated.name}'...`));
} else {
console.log(chalk.cyan('Updating all marketplaces...'));
}
console.log('');
const results = await updateMarketplace(validated.name);
for (const result of results) {
if (result.hasChanges) {
console.log(chalk.green(`${result.name}: Updated`));
if (result.previousSha && result.newSha) {
console.log(
chalk.dim(
` ${result.previousSha.substring(0, 8)}${result.newSha.substring(0, 8)}`
)
);
}
} else {
console.log(chalk.dim(`${result.name}: Already up to date`));
}
// Show warnings
if (result.warnings.length > 0) {
for (const warning of result.warnings) {
console.log(chalk.yellow(` - ${warning}`));
}
}
}
console.log('');
}
/**
* Handles the `dexto plugin marketplace list` command.
* Lists all registered marketplaces.
*/
export async function handleMarketplaceListCommand(
options: MarketplaceListCommandOptionsInput
): Promise<void> {
const validated = MarketplaceListCommandSchema.parse(options);
const marketplaces = listMarketplaces();
if (marketplaces.length === 0) {
console.log(chalk.yellow('No marketplaces registered.'));
console.log('');
console.log('Add a marketplace with:');
console.log(chalk.cyan(' dexto plugin marketplace add <owner/repo>'));
console.log('');
console.log('Examples:');
console.log(chalk.dim(' dexto plugin marketplace add anthropics/claude-plugins-official'));
console.log(
chalk.dim(' dexto plugin marketplace add https://github.com/user/plugins.git')
);
console.log(chalk.dim(' dexto plugin marketplace add ~/my-local-plugins'));
return;
}
console.log(chalk.bold(`Registered Marketplaces (${marketplaces.length}):`));
console.log('');
for (const marketplace of marketplaces) {
const sourceType = chalk.dim(`[${marketplace.source.type}]`);
console.log(` ${chalk.green(marketplace.name)} ${sourceType}`);
if (validated.verbose) {
console.log(chalk.dim(` Source: ${marketplace.source.value}`));
console.log(chalk.dim(` Path: ${marketplace.installLocation}`));
if (marketplace.lastUpdated) {
const date = new Date(marketplace.lastUpdated).toLocaleDateString();
console.log(chalk.dim(` Updated: ${date}`));
}
}
}
console.log('');
}
/**
* Handles the `dexto plugin marketplace plugins [name]` command.
* Lists plugins available in marketplaces.
*/
export async function handleMarketplacePluginsCommand(options: {
marketplace?: string | undefined;
verbose?: boolean | undefined;
}): Promise<void> {
const plugins = listAllMarketplacePlugins();
// Filter by marketplace if specified
const filtered = options.marketplace
? plugins.filter(
(plugin) => plugin.marketplace.toLowerCase() === options.marketplace?.toLowerCase()
)
: plugins;
if (filtered.length === 0) {
if (options.marketplace) {
console.log(chalk.yellow(`No plugins found in marketplace '${options.marketplace}'.`));
} else {
console.log(chalk.yellow('No plugins found in any marketplace.'));
console.log('');
console.log('Make sure you have marketplaces registered:');
console.log(chalk.cyan(' dexto plugin marketplace list'));
}
return;
}
console.log(chalk.bold(`Available Plugins (${filtered.length}):`));
console.log('');
// Group by marketplace
const byMarketplace = new Map<string, typeof filtered>();
for (const plugin of filtered) {
const list = byMarketplace.get(plugin.marketplace) || [];
list.push(plugin);
byMarketplace.set(plugin.marketplace, list);
}
for (const [marketplace, marketplacePlugins] of byMarketplace) {
console.log(chalk.cyan(` ${marketplace}:`));
for (const plugin of marketplacePlugins) {
const version = plugin.version ? chalk.dim(`@${plugin.version}`) : '';
const category = plugin.category ? chalk.dim(` [${plugin.category}]`) : '';
console.log(` ${chalk.green(plugin.name)}${version}${category}`);
if (options.verbose && plugin.description) {
console.log(chalk.dim(` ${plugin.description}`));
}
}
console.log('');
}
console.log('Install a plugin with:');
console.log(chalk.cyan(' dexto plugin marketplace install <name>@<marketplace>'));
console.log('');
}
/**
* Handles the `dexto plugin marketplace install <plugin>` command.
* Installs a plugin from a registered marketplace.
*/
export async function handleMarketplaceInstallCommand(
options: MarketplaceInstallCommandOptionsInput
): Promise<void> {
const validated = MarketplaceInstallCommandSchema.parse(options);
console.log(chalk.cyan(`Installing plugin '${validated.plugin}' from marketplace...`));
console.log('');
const result = await installPluginFromMarketplace(validated.plugin, {
scope: validated.scope as PluginInstallScope,
force: validated.force,
});
// Show warnings
if (result.warnings.length > 0) {
console.log(chalk.yellow('Warnings:'));
for (const warning of result.warnings) {
console.log(chalk.yellow(` - ${warning}`));
}
console.log('');
}
console.log(chalk.green(`Successfully installed plugin '${result.pluginName}'`));
console.log(chalk.dim(` Marketplace: ${result.marketplace}`));
console.log(chalk.dim(` Path: ${result.installPath}`));
if (result.gitCommitSha) {
console.log(chalk.dim(` Version: ${result.gitCommitSha.substring(0, 8)}`));
}
console.log('');
}
// === Helper Functions ===
/**
* Gets a display label for the plugin source.
*/
function getSourceLabel(_source: 'dexto'): string {
return chalk.blue('(dexto)');
}

View File

@@ -0,0 +1,285 @@
/**
* Non-Interactive Session Management Commands
*
* This module provides CLI commands for managing sessions outside of interactive mode.
* These commands allow users to manage sessions via direct CLI invocation.
*/
import chalk from 'chalk';
import { logger, DextoAgent, type SessionMetadata } from '@dexto/core';
import { formatSessionInfo, formatHistoryMessage } from './helpers/formatters.js';
/**
* Escape special regex characters in a string to prevent ReDoS attacks
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Helper to get most recent session info (for non-interactive CLI)
* Non-interactive commands don't have a "current session" concept,
* so we use the most recently active session as a sensible default.
*/
async function getMostRecentSessionInfo(
agent: DextoAgent
): Promise<{ id: string; metadata: SessionMetadata | undefined } | null> {
const sessionIds = await agent.listSessions();
if (sessionIds.length === 0) {
return null;
}
// Find most recently active session
let mostRecentId: string | null = null;
let mostRecentActivity = 0;
for (const sessionId of sessionIds) {
const metadata = await agent.getSessionMetadata(sessionId);
if (metadata && metadata.lastActivity > mostRecentActivity) {
mostRecentActivity = metadata.lastActivity;
mostRecentId = sessionId;
}
}
if (!mostRecentId) {
return null;
}
const metadata = await agent.getSessionMetadata(mostRecentId);
return { id: mostRecentId, metadata };
}
/**
* Helper to display session history with consistent formatting
*/
async function displaySessionHistory(sessionId: string, agent: DextoAgent): Promise<void> {
console.log(chalk.blue(`\n💬 Session History for: ${chalk.bold(sessionId)}\n`));
const history = await agent.getSessionHistory(sessionId);
if (history.length === 0) {
console.log(chalk.gray(' No messages in this session yet.\n'));
return;
}
// Display each message with formatting
history.forEach((message, index) => {
console.log(formatHistoryMessage(message, index));
});
console.log(chalk.gray(`\n Total: ${history.length} messages`));
}
/**
* List all available sessions
*/
export async function handleSessionListCommand(agent: DextoAgent): Promise<void> {
try {
console.log(chalk.bold.blue('\n📋 Sessions:\n'));
const sessionIds = await agent.listSessions();
const mostRecent = await getMostRecentSessionInfo(agent);
if (sessionIds.length === 0) {
console.log(
chalk.gray(
' No sessions found. Run `dexto` to start a new session, or use `dexto -c`/`dexto -r <id>`.\n'
)
);
return;
}
// Fetch metadata concurrently; errors do not abort listing
const entries = await Promise.all(
sessionIds.map(async (id) => {
try {
const metadata = await agent.getSessionMetadata(id);
return { id, metadata };
} catch (e) {
logger.error(
`Failed to fetch metadata for session ${id}: ${e instanceof Error ? e.message : String(e)}`,
null,
'red'
);
return { id, metadata: undefined as SessionMetadata | undefined };
}
})
);
let displayed = 0;
for (const { id, metadata } of entries) {
if (!metadata) continue;
// Mark most recent session with indicator (instead of "current")
const isMostRecent = mostRecent ? id === mostRecent.id : false;
console.log(` ${formatSessionInfo(id, metadata, isMostRecent)}`);
displayed++;
}
console.log(chalk.gray(`\n Total: ${displayed} of ${sessionIds.length} sessions`));
console.log(chalk.gray(' 💡 Use `dexto -r <id>` to resume a session\n'));
} catch (error) {
logger.error(
`Failed to list sessions: ${error instanceof Error ? error.message : String(error)}`,
null,
'red'
);
throw error;
}
}
/**
* Show session history
*/
export async function handleSessionHistoryCommand(
agent: DextoAgent,
sessionId?: string
): Promise<void> {
try {
// Use provided session ID or most recent session
let targetSessionId = sessionId;
if (!targetSessionId) {
const recentSession = await getMostRecentSessionInfo(agent);
if (!recentSession) {
console.log(chalk.red('❌ No sessions found'));
console.log(chalk.gray(' Create a session first by running: dexto'));
throw new Error('No sessions found');
}
targetSessionId = recentSession.id;
console.log(chalk.gray(`Using most recent session: ${targetSessionId}\n`));
}
await displaySessionHistory(targetSessionId, agent);
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
console.log(chalk.red(`❌ Session not found: ${sessionId || 'current'}`));
console.log(chalk.gray(' Use `dexto session list` to see available sessions'));
} else if (error instanceof Error && error.message !== 'No sessions found') {
logger.error(`Failed to get session history: ${error.message}`, null, 'red');
}
throw error;
}
}
/**
* Delete a session
*/
export async function handleSessionDeleteCommand(
agent: DextoAgent,
sessionId: string
): Promise<void> {
try {
// Note: For non-interactive CLI, there's no concept of "current session"
// We just delete the requested session without restrictions
await agent.deleteSession(sessionId);
console.log(chalk.green(`✅ Deleted session: ${chalk.bold(sessionId)}`));
} catch (error) {
logger.error(
`Failed to delete session: ${error instanceof Error ? error.message : String(error)}`,
null,
'red'
);
throw error;
}
}
/**
* Search session history
*/
export async function handleSessionSearchCommand(
agent: DextoAgent,
query: string,
options: {
sessionId?: string;
role?: 'user' | 'assistant' | 'system' | 'tool';
limit?: number;
} = {}
): Promise<void> {
try {
const searchOptions: {
limit: number;
sessionId?: string;
role?: 'user' | 'assistant' | 'system' | 'tool';
} = {
limit: options.limit || 10,
};
if (options.sessionId) {
searchOptions.sessionId = options.sessionId;
}
if (options.role) {
const allowed = new Set(['user', 'assistant', 'system', 'tool']);
if (!allowed.has(options.role)) {
console.log(
chalk.red(
`❌ Invalid role: ${options.role}. Use one of: user, assistant, system, tool`
)
);
return;
}
searchOptions.role = options.role;
}
console.log(chalk.blue(`🔍 Searching for: "${query}"`));
if (searchOptions.sessionId) {
console.log(chalk.gray(` Session: ${searchOptions.sessionId}`));
}
if (searchOptions.role) {
console.log(chalk.gray(` Role: ${searchOptions.role}`));
}
console.log(chalk.gray(` Limit: ${searchOptions.limit}`));
console.log();
const results = await agent.searchMessages(query, searchOptions);
if (results.results.length === 0) {
console.log(chalk.rgb(255, 165, 0)('📭 No messages found matching your search'));
return;
}
console.log(
chalk.green(`✅ Found ${results.total} result${results.total === 1 ? '' : 's'}`)
);
if (results.hasMore) {
console.log(chalk.gray(` Showing first ${results.results.length} results`));
}
console.log();
// Precompile safe regex for highlighting (only if query is not empty)
const highlightRegex = query.trim()
? new RegExp(`(${escapeRegExp(query.trim().slice(0, 256))})`, 'gi')
: null;
// Display results
results.results.forEach((result, index) => {
const roleColor =
result.message.role === 'user'
? chalk.blue
: result.message.role === 'assistant'
? chalk.green
: chalk.rgb(255, 165, 0);
console.log(
`${chalk.gray(`${index + 1}.`)} ${chalk.cyan(result.sessionId)} ${roleColor(`[${result.message.role}]`)}`
);
// Safe highlighting - only if we have a valid regex
const highlightedContext = highlightRegex
? result.context.replace(highlightRegex, chalk.inverse('$1'))
: result.context;
console.log(` ${highlightedContext}`);
console.log();
});
if (results.hasMore) {
console.log(chalk.gray('💡 Use --limit to see more results'));
}
} catch (error) {
logger.error(
`Search failed: ${error instanceof Error ? error.message : String(error)}`,
null,
'red'
);
throw error;
}
}

View File

@@ -0,0 +1,754 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { handleSetupCommand, type CLISetupOptionsInput } from './setup.js';
// Mock only external dependencies that can't be tested directly
vi.mock('@dexto/core', async () => {
const actual = await vi.importActual<typeof import('@dexto/core')>('@dexto/core');
return {
...actual,
resolveApiKeyForProvider: vi.fn(),
requiresApiKey: vi.fn(() => true), // Most providers need API keys
};
});
vi.mock('@dexto/agent-management', async () => {
const actual =
await vi.importActual<typeof import('@dexto/agent-management')>('@dexto/agent-management');
return {
...actual,
createInitialPreferences: vi.fn((...args: any[]) => {
const options = args[0];
// Handle new options object signature
if (typeof options === 'object' && 'provider' in options) {
const llmConfig: any = { provider: options.provider, model: options.model };
if (options.apiKeyVar) {
llmConfig.apiKey = `$${options.apiKeyVar}`;
}
if (options.baseURL) {
llmConfig.baseURL = options.baseURL;
}
return {
llm: llmConfig,
defaults: {
defaultAgent: options.defaultAgent || 'coding-agent',
defaultMode: options.defaultMode || 'web',
},
setup: { completed: options.setupCompleted ?? true },
};
}
// Legacy signature (provider, model, apiKeyVar, defaultAgent)
return {
llm: { provider: options, model: args[1], apiKey: `$${args[2]}` },
defaults: { defaultAgent: args[3] || 'coding-agent' },
setup: { completed: true },
};
}),
saveGlobalPreferences: vi.fn().mockResolvedValue(undefined),
loadGlobalPreferences: vi.fn().mockResolvedValue(null),
getGlobalPreferencesPath: vi.fn(() => '/tmp/preferences.yml'),
updateGlobalPreferences: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('../utils/api-key-setup.js', () => ({
interactiveApiKeySetup: vi.fn().mockResolvedValue({ success: true }),
hasApiKeyConfigured: vi.fn(() => true),
}));
vi.mock('../utils/provider-setup.js', () => ({
selectProvider: vi.fn(),
getProviderDisplayName: vi.fn((provider: string) => provider),
getProviderEnvVar: vi.fn((provider: string) => `${provider.toUpperCase()}_API_KEY`),
getProviderInfo: vi.fn(() => ({ apiKeyUrl: 'https://example.com' })),
providerRequiresBaseURL: vi.fn(() => false),
getDefaultModel: vi.fn(() => 'test-model'),
}));
vi.mock('../utils/setup-utils.js', () => ({
requiresSetup: vi.fn(),
}));
vi.mock('@clack/prompts', () => ({
intro: vi.fn(),
note: vi.fn(),
outro: vi.fn(),
confirm: vi.fn(),
cancel: vi.fn(),
isCancel: vi.fn(),
select: vi.fn().mockResolvedValue('exit'),
text: vi.fn().mockResolvedValue('test'),
password: vi.fn().mockResolvedValue('test-key'),
spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
log: { warn: vi.fn(), success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
describe('Setup Command', () => {
let tempDir: string;
let mockCreateInitialPreferences: any;
let mockSaveGlobalPreferences: any;
let mockInteractiveApiKeySetup: any;
let mockHasApiKeyConfigured: any;
let mockSelectProvider: any;
let mockRequiresSetup: any;
let mockResolveApiKeyForProvider: any;
let mockPrompts: any;
let consoleSpy: any;
let consoleErrorSpy: any;
let processExitSpy: any;
function createTempDir() {
return fs.mkdtempSync(path.join(tmpdir(), 'setup-test-'));
}
beforeEach(async () => {
vi.clearAllMocks();
tempDir = createTempDir();
// Get mock functions
const prefLoader = await import('@dexto/agent-management');
const apiKeySetup = await import('../utils/api-key-setup.js');
const apiKeyResolver = await import('@dexto/core');
const providerSetup = await import('../utils/provider-setup.js');
const setupUtils = await import('../utils/setup-utils.js');
const prompts = await import('@clack/prompts');
mockCreateInitialPreferences = vi.mocked(prefLoader.createInitialPreferences);
mockSaveGlobalPreferences = vi.mocked(prefLoader.saveGlobalPreferences);
mockInteractiveApiKeySetup = vi.mocked(apiKeySetup.interactiveApiKeySetup);
mockHasApiKeyConfigured = vi.mocked(apiKeySetup.hasApiKeyConfigured);
mockResolveApiKeyForProvider = vi.mocked(apiKeyResolver.resolveApiKeyForProvider);
mockSelectProvider = vi.mocked(providerSetup.selectProvider);
mockRequiresSetup = vi.mocked(setupUtils.requiresSetup);
mockPrompts = {
intro: vi.mocked(prompts.intro),
note: vi.mocked(prompts.note),
confirm: vi.mocked(prompts.confirm),
cancel: vi.mocked(prompts.cancel),
isCancel: vi.mocked(prompts.isCancel),
select: vi.mocked(prompts.select),
log: {
warn: vi.mocked(prompts.log.warn),
success: vi.mocked(prompts.log.success),
info: vi.mocked(prompts.log.info),
},
};
// Reset mocks to default behavior - use new options object signature
mockCreateInitialPreferences.mockImplementation((...args: any[]) => {
const options = args[0];
if (typeof options === 'object' && 'provider' in options) {
const llmConfig: any = { provider: options.provider, model: options.model };
if (options.apiKeyVar) {
llmConfig.apiKey = `$${options.apiKeyVar}`;
}
return {
llm: llmConfig,
defaults: {
defaultAgent: options.defaultAgent || 'coding-agent',
defaultMode: options.defaultMode || 'web',
},
setup: { completed: options.setupCompleted ?? true },
};
}
return {
llm: { provider: options, model: args[1], apiKey: `$${args[2]}` },
defaults: { defaultAgent: args[3] || 'coding-agent' },
setup: { completed: true },
};
});
mockSaveGlobalPreferences.mockResolvedValue(undefined);
mockInteractiveApiKeySetup.mockResolvedValue({ success: true });
mockHasApiKeyConfigured.mockReturnValue(true); // Default: API key exists
mockResolveApiKeyForProvider.mockReturnValue(undefined); // Default: no API key exists (for analytics)
mockSelectProvider.mockResolvedValue(null);
mockRequiresSetup.mockResolvedValue(true); // Default: setup is required
mockPrompts.isCancel.mockReturnValue(false);
mockPrompts.select.mockResolvedValue('exit'); // Default: exit settings menu
// Mock console to prevent test output noise
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code?: string | number | null | undefined) => {
throw new Error(`Process exit called with code ${code}`);
});
});
afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
processExitSpy.mockRestore();
});
describe('Non-interactive setup', () => {
it('creates preferences with provided options using new object signature', async () => {
const options: CLISetupOptionsInput = {
provider: 'openai',
model: 'gpt-5',
defaultAgent: 'my-agent',
interactive: false,
};
await handleSetupCommand(options);
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
provider: 'openai',
model: 'gpt-5',
apiKeyVar: 'OPENAI_API_KEY',
defaultAgent: 'my-agent',
setupCompleted: true,
});
expect(mockSaveGlobalPreferences).toHaveBeenCalled();
expect(mockInteractiveApiKeySetup).not.toHaveBeenCalled();
});
it('uses default model when not specified', async () => {
const options = {
provider: 'anthropic' as const,
interactive: false,
};
await handleSetupCommand(options);
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
provider: 'anthropic',
model: 'test-model', // From mocked getDefaultModel
apiKeyVar: 'ANTHROPIC_API_KEY',
defaultAgent: 'coding-agent',
setupCompleted: true,
});
});
it('throws error when provider missing in non-interactive mode', async () => {
const options = {
interactive: false,
};
await expect(handleSetupCommand(options)).rejects.toThrow(
'Provider required in non-interactive mode. Use --provider or --quick-start option.'
);
});
it('exits with error when model required but not provided', async () => {
// Mock getDefaultModel to return empty string for this provider (simulating no default)
const providerSetup = await import('../utils/provider-setup.js');
vi.mocked(providerSetup.getDefaultModel).mockReturnValueOnce('');
const options = {
provider: 'openai-compatible' as const, // Provider with no default model
interactive: false,
};
await expect(handleSetupCommand(options)).rejects.toThrow(
'Process exit called with code 1'
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Model is required for provider 'openai-compatible'")
);
});
});
describe('Interactive setup', () => {
it('shows setup type selection when interactive without provider', async () => {
// User selects 'custom' setup, then provider selection happens
// Wizard uses selectProvider for provider selection
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
mockSelectProvider.mockResolvedValueOnce('anthropic'); // Provider (via selectProvider)
mockPrompts.select.mockResolvedValueOnce('claude-haiku-4-5-20251001'); // Model
mockPrompts.select.mockResolvedValueOnce('web'); // Default mode
const options = {
interactive: true,
};
await handleSetupCommand(options);
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'anthropic',
defaultMode: 'web',
setupCompleted: true,
})
);
});
it('handles quick start selection in interactive mode', async () => {
// User selects 'quick' setup
mockPrompts.select.mockResolvedValueOnce('quick'); // Setup type -> quick start
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker -> google
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation -> yes
mockHasApiKeyConfigured.mockReturnValue(true); // API key already configured
const options = {
interactive: true,
};
await handleSetupCommand(options);
// Quick start uses Google provider with CLI mode
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'google',
defaultMode: 'cli',
setupCompleted: true,
})
);
});
it('runs interactive API key setup when no API key exists', async () => {
// New wizard flow uses p.select for setup type, selectProvider for provider
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
mockSelectProvider.mockResolvedValueOnce('openai'); // Provider (via selectProvider)
mockPrompts.select.mockResolvedValueOnce('gpt-4o'); // Model (must be valid OpenAI model from registry)
mockPrompts.select.mockResolvedValueOnce('web'); // Default mode
mockHasApiKeyConfigured.mockReturnValue(false); // No API key exists
const options = {
interactive: true,
};
await handleSetupCommand(options);
// API key setup is called with provider and model
expect(mockInteractiveApiKeySetup).toHaveBeenCalledWith(
'openai',
expect.objectContaining({
exitOnCancel: false,
})
);
});
it('skips interactive API key setup when API key already exists', async () => {
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
mockSelectProvider.mockResolvedValueOnce('openai'); // Provider (via selectProvider)
mockPrompts.select.mockResolvedValueOnce('gpt-4'); // Model
mockPrompts.select.mockResolvedValueOnce('web'); // Default mode
mockHasApiKeyConfigured.mockReturnValue(true); // API key exists
const options = {
interactive: true,
};
await handleSetupCommand(options);
expect(mockInteractiveApiKeySetup).not.toHaveBeenCalled();
expect(mockPrompts.log.success).toHaveBeenCalled();
});
it('cancels setup when user cancels setup type selection', async () => {
mockPrompts.select.mockResolvedValueOnce(Symbol.for('cancel')); // Cancel
mockPrompts.isCancel.mockReturnValue(true);
const options = {
interactive: true,
};
await expect(handleSetupCommand(options)).rejects.toThrow(
'Process exit called with code 0'
);
});
});
describe('Validation', () => {
it('validates schema correctly with defaults and uses new options signature', async () => {
// Interactive mode with provider - goes through full setup flow
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
mockSelectProvider.mockResolvedValueOnce('google'); // Provider (via selectProvider)
mockPrompts.select.mockResolvedValueOnce('gemini-2.5-pro'); // Model
mockPrompts.select.mockResolvedValueOnce('web'); // Default mode
const options = {
provider: 'google' as const,
};
await handleSetupCommand(options);
// Should apply defaults: interactive=true, defaultAgent='coding-agent'
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'google',
setupCompleted: true,
})
);
});
it('throws ZodError for invalid provider', async () => {
const options = {
provider: 'invalid-provider',
interactive: false,
} as any;
await expect(handleSetupCommand(options)).rejects.toThrow();
});
it('throws validation error for empty model name', async () => {
const options = {
provider: 'openai',
model: '',
interactive: false,
} as any;
await expect(handleSetupCommand(options)).rejects.toThrow();
});
it('throws validation error for empty default agent', async () => {
const options = {
provider: 'openai',
defaultAgent: '',
interactive: false,
} as any;
await expect(handleSetupCommand(options)).rejects.toThrow();
});
it('handles strict mode validation correctly', async () => {
const options = {
provider: 'openai',
unknownField: 'should-cause-error',
interactive: false,
} as any;
await expect(handleSetupCommand(options)).rejects.toThrow();
});
});
describe('Error handling', () => {
it('propagates errors from createInitialPreferences', async () => {
mockCreateInitialPreferences.mockImplementation(() => {
throw new Error('Failed to create preferences');
});
const options = {
provider: 'openai' as const,
interactive: false,
};
await expect(handleSetupCommand(options)).rejects.toThrow(
'Failed to create preferences'
);
});
it('propagates errors from saveGlobalPreferences', async () => {
// Reset createInitialPreferences to new options signature
mockCreateInitialPreferences.mockImplementation((options: any) => ({
llm: {
provider: options.provider,
model: options.model,
apiKey: `$${options.apiKeyVar}`,
},
defaults: {
defaultAgent: options.defaultAgent || 'coding-agent',
defaultMode: 'web',
},
setup: { completed: true },
}));
mockSaveGlobalPreferences.mockRejectedValue(new Error('Failed to save preferences'));
const options = {
provider: 'openai' as const,
interactive: false,
};
await expect(handleSetupCommand(options)).rejects.toThrow('Failed to save preferences');
});
it('propagates errors from interactiveApiKeySetup', async () => {
// Reset to new options signature
mockCreateInitialPreferences.mockImplementation((options: any) => ({
llm: {
provider: options.provider,
model: options.model,
apiKey: `$${options.apiKeyVar}`,
},
defaults: {
defaultAgent: options.defaultAgent || 'coding-agent',
defaultMode: 'web',
},
setup: { completed: true },
}));
mockSaveGlobalPreferences.mockResolvedValue(undefined);
mockHasApiKeyConfigured.mockReturnValue(false); // No API key exists
// Simulate a thrown error (not just a failed result)
mockInteractiveApiKeySetup.mockRejectedValue(new Error('API key setup failed'));
// Setup mocks for interactive flow
mockPrompts.select.mockResolvedValueOnce('custom'); // Setup type
mockSelectProvider.mockResolvedValueOnce('openai'); // Provider (via selectProvider)
mockPrompts.select.mockResolvedValueOnce('gpt-4'); // Model
mockPrompts.select.mockResolvedValueOnce('web'); // Mode (won't be reached due to error)
const options = {
interactive: true,
};
await expect(handleSetupCommand(options)).rejects.toThrow('API key setup failed');
});
});
describe('Edge cases', () => {
it('works correctly with multiple providers in non-interactive mode', async () => {
const testCases = [
{
provider: 'openai',
expectedKey: 'OPENAI_API_KEY',
},
{
provider: 'anthropic',
expectedKey: 'ANTHROPIC_API_KEY',
},
{
provider: 'google',
expectedKey: 'GOOGLE_API_KEY',
},
] as const;
for (const testCase of testCases) {
// Reset mocks for each test case
mockCreateInitialPreferences.mockClear();
mockSaveGlobalPreferences.mockClear();
mockInteractiveApiKeySetup.mockClear();
const options = {
provider: testCase.provider,
interactive: false,
};
await handleSetupCommand(options);
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
provider: testCase.provider,
model: 'test-model', // From mocked getDefaultModel
apiKeyVar: testCase.expectedKey,
defaultAgent: 'coding-agent',
setupCompleted: true,
});
}
});
it('preserves user-provided model over default', async () => {
// Reset to new options signature
mockCreateInitialPreferences.mockImplementation((options: any) => ({
llm: {
provider: options.provider,
model: options.model,
apiKey: `$${options.apiKeyVar}`,
},
defaults: {
defaultAgent: options.defaultAgent || 'coding-agent',
defaultMode: 'web',
},
setup: { completed: true },
}));
const options = {
provider: 'openai' as const,
model: 'gpt-5-mini',
interactive: false,
};
await handleSetupCommand(options);
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
provider: 'openai',
model: 'gpt-5-mini', // User-specified model, not default
apiKeyVar: 'OPENAI_API_KEY',
defaultAgent: 'coding-agent',
setupCompleted: true,
});
});
});
describe('Re-setup scenarios', () => {
beforeEach(() => {
// Setup is already complete for these tests
mockRequiresSetup.mockResolvedValue(false);
});
describe('Non-interactive re-setup', () => {
it('errors without --force flag when setup is already complete', async () => {
const options = {
provider: 'openai' as const,
interactive: false,
force: false,
};
await expect(handleSetupCommand(options)).rejects.toThrow(
'Process exit called with code 1'
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Setup is already complete')
);
expect(mockCreateInitialPreferences).not.toHaveBeenCalled();
});
it('proceeds with --force flag when setup is already complete', async () => {
const options = {
provider: 'openai' as const,
interactive: false,
force: true,
};
await handleSetupCommand(options);
expect(mockCreateInitialPreferences).toHaveBeenCalledWith({
provider: 'openai',
model: 'test-model', // From mocked getDefaultModel
apiKeyVar: 'OPENAI_API_KEY',
defaultAgent: 'coding-agent',
setupCompleted: true,
});
expect(mockSaveGlobalPreferences).toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
});
describe('Interactive re-setup (Settings Menu)', () => {
it('shows settings menu when setup is already complete', async () => {
// User selects 'exit' from settings menu
mockPrompts.select.mockResolvedValueOnce('exit');
const options = {
interactive: true,
};
await handleSetupCommand(options);
// Should show settings menu intro
expect(mockPrompts.intro).toHaveBeenCalledWith(expect.stringContaining('Settings'));
// Should not try to create new preferences when exiting
expect(mockCreateInitialPreferences).not.toHaveBeenCalled();
});
it('exits gracefully when user cancels from settings menu', async () => {
mockPrompts.select.mockResolvedValueOnce(Symbol.for('cancel'));
mockPrompts.isCancel.mockReturnValue(true);
const options = {
interactive: true,
};
await handleSetupCommand(options);
// Should not throw, just exit gracefully
expect(mockCreateInitialPreferences).not.toHaveBeenCalled();
});
});
it('proceeds normally when setup is required despite preferences existing', async () => {
// Edge case: preferences exist but are incomplete/corrupted
mockRequiresSetup.mockResolvedValue(true);
const options = {
provider: 'openai' as const,
interactive: false,
};
await handleSetupCommand(options);
expect(mockCreateInitialPreferences).toHaveBeenCalled();
expect(mockSaveGlobalPreferences).toHaveBeenCalled();
});
});
describe('Quick start flow', () => {
it('handles --quick-start flag in non-interactive mode', async () => {
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation
mockHasApiKeyConfigured.mockReturnValue(true);
const options = {
quickStart: true,
interactive: false,
};
// Note: quickStart triggers the quick start flow even in non-interactive
await handleSetupCommand(options);
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'google',
defaultMode: 'cli',
setupCompleted: true,
})
);
});
it('prompts for API key if not configured during quick start', async () => {
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation
mockHasApiKeyConfigured.mockReturnValue(false);
mockInteractiveApiKeySetup.mockResolvedValue({ success: true });
const options = {
quickStart: true,
};
await handleSetupCommand(options);
expect(mockInteractiveApiKeySetup).toHaveBeenCalledWith(
'google',
expect.objectContaining({
exitOnCancel: false,
})
);
});
it('handles API key skip during quick start', async () => {
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation
mockHasApiKeyConfigured.mockReturnValue(false);
mockInteractiveApiKeySetup.mockResolvedValue({ success: true, skipped: true });
const options = {
quickStart: true,
};
await handleSetupCommand(options);
// Should save preferences with apiKeyPending flag set to true
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'google',
apiKeyPending: true,
setupCompleted: true,
})
);
expect(mockSaveGlobalPreferences).toHaveBeenCalled();
});
it('sets apiKeyPending to false when API key is provided', async () => {
// Reset mocks to ensure clean state
mockPrompts.select.mockReset();
mockPrompts.confirm.mockReset();
mockPrompts.select.mockResolvedValueOnce('google'); // Provider picker
mockPrompts.confirm.mockResolvedValueOnce(true); // CLI mode confirmation
mockHasApiKeyConfigured.mockReturnValue(false);
// interactiveApiKeySetup returns success without skipped flag - API key was provided
mockInteractiveApiKeySetup.mockResolvedValue({ success: true, apiKey: 'test-key' });
const options = {
quickStart: true,
};
await handleSetupCommand(options);
// Should save preferences with apiKeyPending false
expect(mockCreateInitialPreferences).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'google',
apiKeyPending: false,
setupCompleted: true,
})
);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import { shouldPromptForSync, markSyncDismissed, clearSyncDismissed } from './sync-agents.js';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual('fs');
return {
...actual,
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
access: vi.fn(),
unlink: vi.fn(),
},
};
});
// Mock agent-management
const mockLoadBundledRegistryAgents = vi.fn();
const mockResolveBundledScript = vi.fn();
vi.mock('@dexto/agent-management', () => ({
getDextoGlobalPath: vi.fn((type: string, filename?: string) => {
if (type === 'agents') return '/mock/.dexto/agents';
if (type === 'cache')
return filename ? `/mock/.dexto/cache/${filename}` : '/mock/.dexto/cache';
return '/mock/.dexto';
}),
loadBundledRegistryAgents: () => mockLoadBundledRegistryAgents(),
resolveBundledScript: (path: string) => mockResolveBundledScript(path),
copyDirectory: vi.fn(),
}));
describe('sync-agents', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('shouldPromptForSync', () => {
it('returns false when sync was dismissed for current version', async () => {
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: '1.0.0' }));
const result = await shouldPromptForSync('1.0.0');
expect(result).toBe(false);
});
it('checks for updates when not dismissed', async () => {
// Dismissed file doesn't exist
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
// No installed agents
vi.mocked(fs.readdir).mockResolvedValue([]);
mockLoadBundledRegistryAgents.mockReturnValue({
'test-agent': { id: 'test-agent', name: 'Test Agent', source: 'test-agent/' },
});
const result = await shouldPromptForSync('1.0.0');
expect(result).toBe(false); // No installed agents = nothing to sync
});
it('returns true when installed agent differs from bundled', async () => {
// Not dismissed
vi.mocked(fs.readFile).mockImplementation(async (path) => {
if (String(path).includes('sync-dismissed')) {
throw new Error('ENOENT');
}
// Return different content for bundled vs installed to simulate hash mismatch
if (String(path).includes('bundled')) {
return Buffer.from('bundled content v2');
}
return Buffer.from('installed content v1');
});
// One installed agent
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'test-agent', isDirectory: () => true } as any,
]);
// Mock stat to return file (not directory) for simpler hash
vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false } as any);
mockLoadBundledRegistryAgents.mockReturnValue({
'test-agent': {
id: 'test-agent',
name: 'Test Agent',
source: 'test-agent.yml',
description: 'A test agent',
author: 'Test',
tags: [],
},
});
mockResolveBundledScript.mockReturnValue('/bundled/agents/test-agent.yml');
const result = await shouldPromptForSync('1.0.0');
expect(result).toBe(true);
});
it('returns false when all installed agents match bundled', async () => {
// Not dismissed
vi.mocked(fs.readFile).mockImplementation(async (path) => {
if (String(path).includes('sync-dismissed')) {
throw new Error('ENOENT');
}
// Return same content for both
return Buffer.from('same content');
});
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'test-agent', isDirectory: () => true } as any,
]);
vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false } as any);
mockLoadBundledRegistryAgents.mockReturnValue({
'test-agent': {
id: 'test-agent',
name: 'Test Agent',
source: 'test-agent.yml',
description: 'A test agent',
author: 'Test',
tags: [],
},
});
mockResolveBundledScript.mockReturnValue('/bundled/agents/test-agent.yml');
const result = await shouldPromptForSync('1.0.0');
expect(result).toBe(false);
});
it('skips custom agents not in bundled registry', async () => {
// Not dismissed
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
// Custom agent installed but not in bundled registry
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'my-custom-agent', isDirectory: () => true } as any,
]);
mockLoadBundledRegistryAgents.mockReturnValue({
'test-agent': { id: 'test-agent', name: 'Test Agent', source: 'test-agent/' },
});
const result = await shouldPromptForSync('1.0.0');
expect(result).toBe(false); // Custom agent is skipped
});
it('returns false on error', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Some error'));
mockLoadBundledRegistryAgents.mockImplementation(() => {
throw new Error('Registry error');
});
const result = await shouldPromptForSync('1.0.0');
expect(result).toBe(false);
});
});
describe('markSyncDismissed', () => {
it('writes dismissed state to file', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue();
await markSyncDismissed('1.5.0');
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('sync-dismissed.json'),
JSON.stringify({ version: '1.5.0' })
);
});
it('does not throw on error', async () => {
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Permission denied'));
await expect(markSyncDismissed('1.5.0')).resolves.not.toThrow();
});
});
describe('clearSyncDismissed', () => {
it('removes dismissed state file', async () => {
vi.mocked(fs.unlink).mockResolvedValue();
await clearSyncDismissed();
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('sync-dismissed.json'));
});
it('does not throw when file does not exist', async () => {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
vi.mocked(fs.unlink).mockRejectedValue(error);
await expect(clearSyncDismissed()).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,594 @@
// packages/cli/src/cli/commands/sync-agents.ts
import { promises as fs } from 'fs';
import { createHash } from 'crypto';
import path from 'path';
import chalk from 'chalk';
import * as p from '@clack/prompts';
import { logger } from '@dexto/core';
import {
getDextoGlobalPath,
resolveBundledScript,
copyDirectory,
loadBundledRegistryAgents,
type AgentRegistryEntry,
} from '@dexto/agent-management';
/**
* Options for the sync-agents command
*/
export interface SyncAgentsCommandOptions {
/** Just list status without updating */
list?: boolean;
/** Update all without prompting */
force?: boolean;
/** Minimal output - used when called from startup prompt */
quiet?: boolean;
}
/**
* Agent sync status
*/
type AgentStatus =
| 'up_to_date'
| 'changes_available'
| 'not_installed'
| 'custom' // User-installed, not in bundled registry
| 'error';
interface AgentInfo {
id: string;
name: string;
description?: string | undefined;
status: AgentStatus;
error?: string | undefined;
}
/**
* Calculate SHA256 hash of a file
*/
async function hashFile(filePath: string): Promise<string> {
const content = await fs.readFile(filePath);
return createHash('sha256').update(content).digest('hex');
}
/**
* Calculate combined hash for a directory
* Hashes all files recursively and combines them
*/
async function hashDirectory(dirPath: string): Promise<string> {
const hash = createHash('sha256');
const files: string[] = [];
async function collectFiles(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await collectFiles(fullPath);
} else {
files.push(fullPath);
}
}
}
await collectFiles(dirPath);
// Sort for consistent ordering
files.sort();
for (const file of files) {
const relativePath = path.relative(dirPath, file);
const content = await fs.readFile(file);
hash.update(relativePath);
hash.update(content);
}
return hash.digest('hex');
}
/**
* Get hash of bundled agent
*/
async function getBundledAgentHash(agentEntry: AgentRegistryEntry): Promise<string | null> {
try {
const sourcePath = resolveBundledScript(`agents/${agentEntry.source}`);
const stat = await fs.stat(sourcePath);
if (stat.isDirectory()) {
return await hashDirectory(sourcePath);
} else {
return await hashFile(sourcePath);
}
} catch (error) {
logger.debug(
`Failed to hash bundled agent: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
/**
* Get hash of installed agent
*/
async function getInstalledAgentHash(agentId: string): Promise<string | null> {
try {
const installedPath = path.join(getDextoGlobalPath('agents'), agentId);
const stat = await fs.stat(installedPath);
if (stat.isDirectory()) {
return await hashDirectory(installedPath);
} else {
return await hashFile(installedPath);
}
} catch (error) {
logger.debug(
`Failed to hash installed agent: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
/**
* Check if an agent is installed
*/
async function isAgentInstalled(agentId: string): Promise<boolean> {
try {
const installedPath = path.join(getDextoGlobalPath('agents'), agentId);
await fs.access(installedPath);
return true;
} catch {
return false;
}
}
/**
* Get list of all installed agent directories
*/
async function getInstalledAgentIds(): Promise<string[]> {
try {
const agentsDir = getDextoGlobalPath('agents');
const entries = await fs.readdir(agentsDir, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch (error) {
logger.debug(
`Failed to list installed agents: ${error instanceof Error ? error.message : String(error)}`
);
return [];
}
}
/**
* Get agent status by comparing bundled vs installed
*/
async function getAgentStatus(agentId: string, agentEntry: AgentRegistryEntry): Promise<AgentInfo> {
const installed = await isAgentInstalled(agentId);
if (!installed) {
return {
id: agentId,
name: agentEntry.name,
description: agentEntry.description,
status: 'not_installed',
};
}
try {
const bundledHash = await getBundledAgentHash(agentEntry);
const installedHash = await getInstalledAgentHash(agentId);
if (!bundledHash || !installedHash) {
return {
id: agentId,
name: agentEntry.name,
description: agentEntry.description,
status: 'error',
error: 'Could not compute hash',
};
}
if (bundledHash === installedHash) {
return {
id: agentId,
name: agentEntry.name,
description: agentEntry.description,
status: 'up_to_date',
};
} else {
return {
id: agentId,
name: agentEntry.name,
description: agentEntry.description,
status: 'changes_available',
};
}
} catch (error) {
return {
id: agentId,
name: agentEntry.name,
description: agentEntry.description,
status: 'error',
error: error instanceof Error ? error.message : String(error),
};
}
}
// Store sync dismissed state in cache directory
const SYNC_DISMISSED_PATH = getDextoGlobalPath('cache', 'sync-dismissed.json');
/**
* Check if sync was dismissed for current version
*/
async function wasSyncDismissed(currentVersion: string): Promise<boolean> {
try {
const content = await fs.readFile(SYNC_DISMISSED_PATH, 'utf-8');
const data = JSON.parse(content) as { version: string };
return data.version === currentVersion;
} catch (error) {
logger.debug(
`Could not read sync dismissed state: ${error instanceof Error ? error.message : String(error)}`
);
return false;
}
}
/**
* Mark sync as dismissed for current version
*/
export async function markSyncDismissed(currentVersion: string): Promise<void> {
try {
await fs.mkdir(path.dirname(SYNC_DISMISSED_PATH), { recursive: true });
await fs.writeFile(SYNC_DISMISSED_PATH, JSON.stringify({ version: currentVersion }));
} catch (error) {
logger.debug(
`Could not save sync dismissed state: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Clear sync dismissed state (called after successful sync)
*/
export async function clearSyncDismissed(): Promise<void> {
try {
await fs.unlink(SYNC_DISMISSED_PATH);
} catch (error) {
// File might not exist - only log if it's a different error
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.debug(
`Could not clear sync dismissed state: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
/**
* Quick check if any installed agents have updates available
*
* Used at CLI startup to prompt for sync without full command output.
* Returns true if at least one installed agent differs from bundled
* AND the user hasn't dismissed the prompt for this version.
*
* @param currentVersion Current CLI version to check dismissal state
* @returns true if should prompt for sync
*/
export async function shouldPromptForSync(currentVersion: string): Promise<boolean> {
try {
// Check if user already dismissed for this version
if (await wasSyncDismissed(currentVersion)) {
return false;
}
const bundledAgents = loadBundledRegistryAgents();
const installedAgentIds = await getInstalledAgentIds();
for (const agentId of installedAgentIds) {
const agentEntry = bundledAgents[agentId];
// Skip custom agents (not in bundled registry)
if (!agentEntry) continue;
const bundledHash = await getBundledAgentHash(agentEntry);
const installedHash = await getInstalledAgentHash(agentId);
if (bundledHash && installedHash && bundledHash !== installedHash) {
return true;
}
}
return false;
} catch (error) {
logger.debug(
`shouldPromptForSync check failed: ${error instanceof Error ? error.message : String(error)}`
);
return false;
}
}
/**
* Update an agent from bundled to installed
*/
async function updateAgent(agentId: string, agentEntry: AgentRegistryEntry): Promise<void> {
const agentsDir = getDextoGlobalPath('agents');
const targetDir = path.join(agentsDir, agentId);
const sourcePath = resolveBundledScript(`agents/${agentEntry.source}`);
// Ensure agents directory exists
await fs.mkdir(agentsDir, { recursive: true });
// Remove old installation
try {
await fs.rm(targetDir, { recursive: true, force: true });
} catch {
// Ignore if doesn't exist
}
// Copy from bundled source
const stat = await fs.stat(sourcePath);
if (stat.isDirectory()) {
await copyDirectory(sourcePath, targetDir);
} else {
await fs.mkdir(targetDir, { recursive: true });
const targetFile = path.join(targetDir, path.basename(sourcePath));
await fs.copyFile(sourcePath, targetFile);
}
}
/**
* Display agent status with appropriate colors
*/
function formatStatus(status: AgentStatus): string {
switch (status) {
case 'up_to_date':
return chalk.green('Up to date');
case 'changes_available':
return chalk.yellow('Changes available');
case 'not_installed':
return chalk.gray('Not installed');
case 'custom':
return chalk.blue('Custom (user-installed)');
case 'error':
return chalk.red('Error');
default:
return chalk.gray('Unknown');
}
}
/**
* Main handler for the sync-agents command
*
* @param options Command options
*
* @example
* ```bash
* dexto sync-agents # Interactive - prompt for each
* dexto sync-agents --list # Show what would be updated
* dexto sync-agents --force # Update all without prompting
* ```
*/
export async function handleSyncAgentsCommand(options: SyncAgentsCommandOptions): Promise<void> {
const { list = false, force = false, quiet = false } = options;
if (!quiet) {
p.intro(chalk.cyan('Agent Sync'));
}
const spinner = p.spinner();
spinner.start('Checking agent configs...');
try {
// Load bundled registry (uses existing function from agent-management)
const bundledAgents = loadBundledRegistryAgents();
const bundledAgentIds = Object.keys(bundledAgents);
// Get installed agents
const installedAgentIds = await getInstalledAgentIds();
// Find custom agents (installed but not in bundled registry)
const customAgentIds = installedAgentIds.filter((id) => !bundledAgents[id]);
// Check status of all bundled agents
const agentInfos: AgentInfo[] = [];
for (const agentId of bundledAgentIds) {
const entry = bundledAgents[agentId];
if (entry) {
const info = await getAgentStatus(agentId, entry);
agentInfos.push(info);
}
}
// Add custom agents
for (const agentId of customAgentIds) {
agentInfos.push({
id: agentId,
name: agentId,
status: 'custom',
});
}
const updatableAgents = agentInfos.filter((a) => a.status === 'changes_available');
const upToDateAgents = agentInfos.filter((a) => a.status === 'up_to_date');
const notInstalledAgents = agentInfos.filter((a) => a.status === 'not_installed');
const customAgents = agentInfos.filter((a) => a.status === 'custom');
const errorAgents = agentInfos.filter((a) => a.status === 'error');
spinner.stop('Agent check complete');
// Quiet mode with force - minimal output for startup prompt
if (quiet && force) {
if (updatableAgents.length === 0) {
p.log.success('All agents up to date');
return;
}
const updatedNames: string[] = [];
const failedNames: string[] = [];
for (const agent of updatableAgents) {
const entry = bundledAgents[agent.id];
if (entry) {
try {
await updateAgent(agent.id, entry);
updatedNames.push(agent.id);
} catch (error) {
failedNames.push(agent.id);
logger.debug(
`Failed to update ${agent.id}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
if (updatedNames.length > 0) {
p.log.success(`Updated: ${updatedNames.join(', ')}`);
}
if (failedNames.length > 0) {
p.log.warn(`Failed to update: ${failedNames.join(', ')}`);
}
return;
}
// Display full status (non-quiet mode)
console.log('');
console.log(chalk.bold('Agent Status:'));
console.log('');
// Show updatable first
for (const agent of updatableAgents) {
console.log(` ${chalk.cyan(agent.id)}:`);
console.log(` Status: ${formatStatus(agent.status)}`);
if (agent.description) {
console.log(` ${chalk.gray(agent.description)}`);
}
console.log('');
}
// Show up-to-date
for (const agent of upToDateAgents) {
console.log(` ${chalk.green(agent.id)}: ${formatStatus(agent.status)}`);
}
// Show not installed (summarized)
if (notInstalledAgents.length > 0) {
console.log('');
console.log(
chalk.gray(
` ${notInstalledAgents.length} agents not installed: ${notInstalledAgents.map((a) => a.id).join(', ')}`
)
);
}
// Show custom
if (customAgents.length > 0) {
console.log('');
for (const agent of customAgents) {
console.log(` ${chalk.blue(agent.id)}: ${formatStatus(agent.status)}`);
}
}
// Show errors
for (const agent of errorAgents) {
console.log(` ${chalk.red(agent.id)}: ${formatStatus(agent.status)}`);
if (agent.error) {
console.log(` ${chalk.red(agent.error)}`);
}
}
console.log('');
// Summary
console.log(chalk.bold('Summary:'));
console.log(` Up to date: ${chalk.green(upToDateAgents.length.toString())}`);
console.log(` Changes available: ${chalk.yellow(updatableAgents.length.toString())}`);
console.log(` Not installed: ${chalk.gray(notInstalledAgents.length.toString())}`);
if (customAgents.length > 0) {
console.log(` Custom: ${chalk.blue(customAgents.length.toString())}`);
}
console.log('');
// If list mode, stop here
if (list) {
p.outro('Use `dexto sync-agents` to update agents');
return;
}
// No updates needed
if (updatableAgents.length === 0) {
p.outro(chalk.green('All installed agents are up to date!'));
return;
}
// Force mode - update all without prompting
if (force) {
const updateSpinner = p.spinner();
updateSpinner.start(`Updating ${updatableAgents.length} agents...`);
let successCount = 0;
let failCount = 0;
for (const agent of updatableAgents) {
const entry = bundledAgents[agent.id];
if (entry) {
try {
await updateAgent(agent.id, entry);
successCount++;
} catch (error) {
failCount++;
logger.error(
`Failed to update ${agent.id}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
updateSpinner.stop(`Updated ${successCount} agents`);
if (failCount > 0) {
p.log.warn(`${failCount} agents failed to update`);
}
p.outro(chalk.green('Sync complete!'));
return;
}
// Interactive mode - prompt for each
for (const agent of updatableAgents) {
const shouldUpdate = await p.confirm({
message: `Update ${chalk.cyan(agent.name)} (${agent.id})?`,
initialValue: true,
});
if (p.isCancel(shouldUpdate)) {
p.cancel('Sync cancelled');
return;
}
if (shouldUpdate) {
const entry = bundledAgents[agent.id];
if (entry) {
try {
const updateSpinner = p.spinner();
updateSpinner.start(`Updating ${agent.id}...`);
await updateAgent(agent.id, entry);
updateSpinner.stop(`Updated ${agent.id}`);
} catch (error) {
p.log.error(
`Failed to update ${agent.id}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
} else {
p.log.info(`Skipped ${agent.id}`);
}
}
p.outro(chalk.green('Sync complete!'));
} catch (error) {
spinner.stop('Error');
p.log.error(
`Failed to check agents: ${error instanceof Error ? error.message : String(error)}`
);
throw error;
}
}

View File

@@ -0,0 +1,191 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Mock the agent-helpers module
vi.mock('../../utils/agent-helpers.js', () => ({
uninstallAgent: vi.fn(),
listInstalledAgents: vi.fn(),
}));
// Mock analytics
vi.mock('../../analytics/index.js', () => ({
capture: vi.fn(),
}));
// Import SUT after mocks
import { handleUninstallCommand } from './uninstall.js';
import { uninstallAgent, listInstalledAgents } from '../../utils/agent-helpers.js';
describe('Uninstall Command', () => {
let consoleSpy: any;
let consoleErrorSpy: any;
beforeEach(() => {
vi.clearAllMocks();
// Mock console
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe('Command validation', () => {
it('rejects when no agents specified and all flag is false', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['some-agent']);
await expect(handleUninstallCommand([], {})).rejects.toThrow(/No agents specified/);
});
it('rejects when no agents are installed', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue([]);
await expect(handleUninstallCommand(['any-agent'], {})).rejects.toThrow(
/No agents are currently installed/
);
expect(uninstallAgent).not.toHaveBeenCalled();
});
it('rejects uninstalling agents that are not installed', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['real-agent']);
await expect(handleUninstallCommand(['fake-agent'], {})).rejects.toThrow(
/not installed/
);
});
});
describe('Single agent uninstall', () => {
it('successfully uninstalls existing agent', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['test-agent']);
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
await expect(handleUninstallCommand(['test-agent'], {})).resolves.not.toThrow();
expect(uninstallAgent).toHaveBeenCalledWith('test-agent');
expect(consoleSpy).toHaveBeenCalled();
});
it('uninstalls agent without force flag', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['default-agent']);
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
await handleUninstallCommand(['default-agent'], {});
expect(uninstallAgent).toHaveBeenCalledWith('default-agent');
expect(uninstallAgent).toHaveBeenCalledTimes(1);
});
});
describe('Bulk uninstall', () => {
it('uninstalls all agents when --all flag is used', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['agent1', 'agent2', 'agent3']);
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
await expect(handleUninstallCommand([], { all: true })).resolves.not.toThrow();
expect(uninstallAgent).toHaveBeenCalledTimes(3);
expect(uninstallAgent).toHaveBeenCalledWith('agent1');
expect(uninstallAgent).toHaveBeenCalledWith('agent2');
expect(uninstallAgent).toHaveBeenCalledWith('agent3');
// Multiple agents show summary
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('📊 Uninstallation Summary')
);
});
it('uninstalls multiple specified agents', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['agent1', 'agent2', 'agent3']);
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
await handleUninstallCommand(['agent1', 'agent2'], {});
expect(uninstallAgent).toHaveBeenCalledTimes(2);
expect(uninstallAgent).toHaveBeenCalledWith('agent1');
expect(uninstallAgent).toHaveBeenCalledWith('agent2');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('📊 Uninstallation Summary')
);
});
});
describe('Error handling', () => {
it('continues with other agents when one fails', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['good-agent', 'bad-agent']);
vi.mocked(uninstallAgent).mockImplementation((agent: string) => {
if (agent === 'bad-agent') {
throw new Error('Cannot remove protected agent');
}
return Promise.resolve(undefined);
});
await expect(
handleUninstallCommand(['good-agent', 'bad-agent'], {})
).resolves.not.toThrow();
expect(uninstallAgent).toHaveBeenCalledTimes(2);
// Should show summary for multiple agents
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('📊 Uninstallation Summary')
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to uninstall bad-agent')
);
});
it('throws when single agent uninstall fails', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['bad-agent']);
vi.mocked(uninstallAgent).mockRejectedValue(new Error('Protection error'));
// Single agent failure should propagate the error directly
await expect(handleUninstallCommand(['bad-agent'], {})).rejects.toThrow();
});
it('shows error in summary when all agents fail in bulk operation', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['agent1', 'agent2']);
vi.mocked(uninstallAgent).mockRejectedValue(new Error('Protection error'));
await expect(handleUninstallCommand(['agent1', 'agent2'], {})).rejects.toThrow(
/All uninstallations failed/
);
expect(uninstallAgent).toHaveBeenCalledTimes(2);
});
it('shows partial success when some agents fail in bulk operation', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['agent1', 'agent2', 'agent3']);
vi.mocked(uninstallAgent).mockImplementation((agent: string) => {
if (agent === 'agent2') {
throw new Error('Failed to uninstall');
}
return Promise.resolve(undefined);
});
await expect(
handleUninstallCommand(['agent1', 'agent2', 'agent3'], {})
).resolves.not.toThrow();
expect(uninstallAgent).toHaveBeenCalledTimes(3);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('📊 Uninstallation Summary')
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('✅ Successfully'));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('❌ Failed'));
});
});
describe('Force flag handling', () => {
it('accepts force flag in options', async () => {
vi.mocked(listInstalledAgents).mockResolvedValue(['test-agent']);
vi.mocked(uninstallAgent).mockResolvedValue(undefined);
// Force flag is in the options but doesn't affect uninstallAgent call
await handleUninstallCommand(['test-agent'], { force: true });
// uninstallAgent only takes agentId, no force parameter
expect(uninstallAgent).toHaveBeenCalledWith('test-agent');
});
});
});

View File

@@ -0,0 +1,160 @@
// packages/cli/src/cli/commands/uninstall.ts
import { z } from 'zod';
import { uninstallAgent, listInstalledAgents } from '../../utils/agent-helpers.js';
import { capture } from '../../analytics/index.js';
// Zod schema for uninstall command validation
const UninstallCommandSchema = z
.object({
agents: z.array(z.string().min(1, 'Agent name cannot be empty')),
all: z.boolean().default(false),
force: z.boolean().default(false),
})
.strict();
export type UninstallCommandOptions = z.output<typeof UninstallCommandSchema>;
/**
* Validate uninstall command arguments
*/
async function validateUninstallCommand(
agents: string[],
options: Partial<UninstallCommandOptions>
): Promise<UninstallCommandOptions> {
// Basic structure validation
const validated = UninstallCommandSchema.parse({
...options,
agents,
});
// Business logic validation
const installedAgents = await listInstalledAgents();
if (installedAgents.length === 0) {
throw new Error('No agents are currently installed.');
}
if (!validated.all && validated.agents.length === 0) {
throw new Error(
`No agents specified. Use agent names or --all flag. Installed agents: ${installedAgents.join(', ')}`
);
}
return validated;
}
export async function handleUninstallCommand(
agents: string[],
options: Partial<UninstallCommandOptions>
): Promise<void> {
// Validate command with Zod
const validated = await validateUninstallCommand(agents, options);
const installedAgents = await listInstalledAgents();
if (installedAgents.length === 0) {
console.log('📋 No agents are currently installed.');
return;
}
// Determine which agents to uninstall
let agentsToUninstall: string[];
if (validated.all) {
agentsToUninstall = installedAgents;
console.log(`📋 Uninstalling all ${agentsToUninstall.length} installed agents...`);
} else {
agentsToUninstall = validated.agents;
// Validate all specified agents are actually installed
const notInstalled = agentsToUninstall.filter((agent) => !installedAgents.includes(agent));
if (notInstalled.length > 0) {
throw new Error(
`Agents not installed: ${notInstalled.join(', ')}. ` +
`Installed agents: ${installedAgents.join(', ')}`
);
}
}
console.log(`🗑️ Uninstalling ${agentsToUninstall.length} agents...`);
let successCount = 0;
let errorCount = 0;
const errors: string[] = [];
const uninstalled: string[] = [];
const failed: string[] = [];
// Uninstall each agent
for (const agentName of agentsToUninstall) {
try {
console.log(`\n🗑 Uninstalling ${agentName}...`);
await uninstallAgent(agentName);
successCount++;
console.log(`${agentName} uninstalled successfully`);
uninstalled.push(agentName);
// Per-agent analytics for successful uninstall
try {
capture('dexto_uninstall_agent', {
agent: agentName,
status: 'uninstalled',
force: validated.force,
});
} catch {
// Analytics failures should not block CLI execution.
}
} catch (error) {
errorCount++;
const errorMsg = `Failed to uninstall ${agentName}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(errorMsg);
failed.push(agentName);
console.error(`${errorMsg}`);
// Per-agent analytics for failed uninstall
try {
capture('dexto_uninstall_agent', {
agent: agentName,
status: 'failed',
error_message: error instanceof Error ? error.message : String(error),
force: validated.force,
});
} catch {
// Analytics failures should not block CLI execution.
}
}
}
// Emit analytics for both single- and multi-agent cases
try {
capture('dexto_uninstall', {
requested: agentsToUninstall,
uninstalled,
failed,
successCount,
errorCount,
});
} catch {
// Analytics failures should not block CLI execution.
}
// For single agent operations, throw error if it failed (after emitting analytics)
if (agentsToUninstall.length === 1) {
if (errorCount > 0) {
throw new Error(errors[0]);
}
return;
}
// Show summary if more than 1 agent uninstalled
console.log(`\n📊 Uninstallation Summary:`);
console.log(`✅ Successfully uninstalled: ${successCount}`);
if (errorCount > 0) {
console.log(`❌ Failed to uninstall: ${errorCount}`);
errors.forEach((error) => console.log(`${error}`));
}
if (errorCount > 0 && successCount === 0) {
throw new Error('All uninstallations failed');
} else if (errorCount > 0) {
console.log(`⚠️ Some uninstallations failed, but ${successCount} succeeded.`);
} else {
console.log(`🎉 All agents uninstalled successfully!`);
}
}

View File

@@ -0,0 +1,50 @@
// packages/cli/src/cli/commands/which.ts
import { readFileSync } from 'fs';
import chalk from 'chalk';
import { z } from 'zod';
import { resolveAgentPath, resolveBundledScript } from '@dexto/agent-management';
// Zod schema for which command validation
const WhichCommandSchema = z
.object({
agentName: z.string().min(1, 'Agent name cannot be empty'),
})
.strict();
export type WhichCommandOptions = z.output<typeof WhichCommandSchema>;
/**
* Load available agent names from bundled registry
*/
function getAvailableAgentNames(): string[] {
try {
const registryPath = resolveBundledScript('agents/agent-registry.json');
const content = readFileSync(registryPath, 'utf-8');
const registry = JSON.parse(content);
return Object.keys(registry.agents || {});
} catch (_error) {
return [];
}
}
/**
* Handle the which command
*/
export async function handleWhichCommand(agentName: string): Promise<void> {
// Validate command with Zod
const validated = WhichCommandSchema.parse({ agentName });
const availableAgents = getAvailableAgentNames();
try {
const resolvedPath = await resolveAgentPath(validated.agentName, false); // Don't auto-install
console.log(resolvedPath);
} catch (error) {
console.error(
chalk.red(
`❌ dexto which command failed: ${error instanceof Error ? error.message : String(error)}. Available agents: ${availableAgents.join(', ')}`
)
);
process.exit(1);
}
}

View File

@@ -0,0 +1,182 @@
# Ink-CLI Architecture
React-based terminal UI built with [Ink](https://github.com/vadimdemedes/ink).
## Entry Point
`InkCLIRefactored.tsx``startInkCliRefactored()`
Two rendering modes (controlled by `USE_ALTERNATE_BUFFER` constant):
- **StaticCLI** (default): Uses Ink's `<Static>` for copy-friendly terminal scrollback
- **AlternateBufferCLI**: Fullscreen with VirtualizedList and mouse support
## State Management
State is managed via **multiple useState hooks** in `useCLIState.ts`.
### Message State (separate arrays for render ordering)
```typescript
messages: Message[] // Finalized → rendered in <Static>
pendingMessages: Message[] // Streaming → rendered dynamically
dequeuedBuffer: Message[] // User messages after pending (ordering fix)
queuedMessages: QueuedMessage[] // Waiting to be processed
```
### CLIState
```typescript
interface CLIState {
input: InputState // value, history, images, pastedBlocks
ui: UIState // isProcessing, activeOverlay, exitWarning
session: SessionState // id, modelName
approval: ApprovalRequest | null
approvalQueue: ApprovalRequest[]
}
```
### Message Interface
```typescript
interface Message {
id: string
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
timestamp: Date
isStreaming?: boolean
toolResult?: string
toolStatus?: 'running' | 'finished'
isError?: boolean // Tool execution failed
styledType?: StyledMessageType // config, stats, help, session-list, etc.
styledData?: StyledData
isContinuation?: boolean // Split message continuation
}
```
## Component Hierarchy
```text
InkCLIRefactored
├── KeypressProvider
├── MouseProvider (alternate buffer only)
├── ScrollProvider (alternate buffer only)
└── StaticCLI / AlternateBufferCLI
├── Header
├── Messages (Static or VirtualizedList)
├── PendingMessages (dynamic, outside Static)
├── StatusBar
├── OverlayContainer
├── InputContainer → TextBufferInput
└── Footer
```
## Input Architecture
### Keyboard Flow
1. `KeypressContext` captures raw stdin
2. `useInputOrchestrator` routes to handlers based on focus
3. Main text input uses its own `useKeypress()` in `TextBufferInput`
### Focus Priority
1. **Approval prompt** (if visible)
2. **Overlay** (if active)
3. **Global shortcuts** (Ctrl+C, Escape)
4. **Main input** (TextBufferInput)
### Global Shortcuts
- **Ctrl+C**: Cancel processing → clear input → exit warning → exit
- **Escape**: Clear exit warning → cancel processing → close overlay
- **Ctrl+S**: Toggle copy mode (alternate buffer only)
## Overlay System
### All Overlay Types
```typescript
type OverlayType =
| 'none'
| 'slash-autocomplete' // /command
| 'resource-autocomplete' // @resource
| 'model-selector' // /model
| 'session-selector' // /session, /resume
| 'session-subcommand-selector'
| 'mcp-selector'
| 'mcp-add-selector'
| 'mcp-remove-selector'
| 'mcp-custom-type-selector'
| 'mcp-custom-wizard'
| 'log-level-selector'
| 'approval'
```
### Detection
Overlays detected by input content (debounced 50ms):
- `/` prefix → slash-autocomplete (or specific selector)
- `@` anywhere → resource-autocomplete
## Key Files
| File | Purpose |
|------|---------|
| `InkCLIRefactored.tsx` | Entry point, provider setup |
| `hooks/useCLIState.ts` | All state management |
| `hooks/useInputOrchestrator.ts` | Keyboard routing |
| `hooks/useAgentEvents.ts` | Event subscriptions |
| `services/processStream.ts` | Streaming handler |
| `services/CommandService.ts` | Command execution |
| `containers/OverlayContainer.tsx` | Overlay management |
| `components/shared/text-buffer.ts` | Input text buffer |
## Critical Rules
### Do
- Use `TextBuffer` as source of truth for input
- Pass explicit `sessionId` to all agent calls
- Check `key.escape` BEFORE checking item count in selectors
- Return `boolean` from handlers (`true` = consumed)
- Use refs + `useImperativeHandle` for component coordination
### Don't
```typescript
// Adding useInput directly to components
useInput((input, key) => { ... }); // Don't do this
// Checking item count before escape
if (items.length === 0) return false;
if (key.escape) { onClose(); return true; } // Never reached!
// Using agent.getCurrentSessionId()
const sessionId = agent.getCurrentSessionId(); // Stale!
// Use state.session.id instead
```
## Common Tasks
### Add New Overlay
1. Add to `OverlayType` in `state/types.ts`
2. Add detection in `useCLIState.ts`
3. Create component in `components/overlays/`
4. Register in `OverlayContainer.tsx`
5. **If overlay has its own text input** (wizard, search, etc.): Add to `overlaysWithOwnInput` array in `InputContainer.tsx` (~line 659) to disable main input while overlay is active
### Add New Slash Command
1. Add to `commands/interactive-commands/commands.ts`
2. Add handler in `CommandService.ts` if needed
3. Appears in autocomplete automatically
## Streaming Architecture
`processStream.ts` handles the async iterator from `agent.stream()`:
- Streaming content → `pendingMessages` (redrawn each frame)
- Finalized content → `messages` (in `<Static>`, rendered once)
- Large content split at markdown boundaries to reduce flickering
- Uses `localPending` mirror to avoid React batching race conditions

View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -0,0 +1,213 @@
/**
* InkCLI Component (Refactored)
*
* Entry point for the Ink-based CLI. Selects between two rendering modes:
* - AlternateBufferCLI: VirtualizedList with mouse scroll, keyboard scroll, copy mode
* - StaticCLI: Static pattern with native terminal scrollback and selection
*
* The mode is selected via USE_ALTERNATE_BUFFER constant.
*/
import React, { useCallback, useState } from 'react';
import { render } from 'ink';
import type { DextoAgent } from '@dexto/core';
import { registerGracefulShutdown } from '../../utils/graceful-shutdown.js';
import { enableBracketedPaste, disableBracketedPaste } from './utils/bracketedPaste.js';
// Types
import type { StartupInfo } from './state/types.js';
// Contexts (keyboard/mouse providers)
import {
KeypressProvider,
MouseProvider,
ScrollProvider,
SoundProvider,
} from './contexts/index.js';
// Sound notification
import type { SoundNotificationService } from './utils/soundNotification.js';
// Components
import { ErrorBoundary } from './components/ErrorBoundary.js';
import { AlternateBufferCLI, StaticCLI } from './components/modes/index.js';
// Hooks
import { useStreaming } from './hooks/useStreaming.js';
// Utils
import { getStartupInfo } from './utils/messageFormatting.js';
// Rendering mode: true = alternate buffer with VirtualizedList, false = Static pattern
// Toggle this to switch between modes for testing
//const USE_ALTERNATE_BUFFER = true;
const USE_ALTERNATE_BUFFER = false;
interface InkCLIProps {
agent: DextoAgent;
initialSessionId: string | null;
startupInfo: StartupInfo;
soundService: SoundNotificationService | null;
}
/**
* Inner component that wraps the mode-specific component with providers
*/
function InkCLIInner({ agent, initialSessionId, startupInfo, soundService }: InkCLIProps) {
// Selection hint callback for alternate buffer mode
const [, setSelectionHintShown] = useState(false);
// Streaming mode - can be toggled via /stream command
const { streaming } = useStreaming();
const handleSelectionAttempt = useCallback(() => {
setSelectionHintShown(true);
}, []);
if (USE_ALTERNATE_BUFFER) {
return (
<SoundProvider soundService={soundService}>
<ScrollProvider onSelectionAttempt={handleSelectionAttempt}>
<AlternateBufferCLI
agent={agent}
initialSessionId={initialSessionId}
startupInfo={startupInfo}
onSelectionAttempt={handleSelectionAttempt}
useStreaming={streaming}
/>
</ScrollProvider>
</SoundProvider>
);
}
// Static mode - no ScrollProvider needed
return (
<SoundProvider soundService={soundService}>
<StaticCLI
agent={agent}
initialSessionId={initialSessionId}
startupInfo={startupInfo}
useStreaming={streaming}
/>
</SoundProvider>
);
}
/**
* Modern CLI interface using React Ink
*
* Wraps the CLI with:
* - ErrorBoundary for graceful error handling
* - KeypressProvider for unified keyboard input
* - MouseProvider (only in alternate buffer mode)
*/
export function InkCLIRefactored({
agent,
initialSessionId,
startupInfo,
soundService,
}: InkCLIProps) {
return (
<ErrorBoundary>
<KeypressProvider>
{/* Mouse events only in alternate buffer mode - Static mode uses native terminal selection */}
<MouseProvider mouseEventsEnabled={USE_ALTERNATE_BUFFER}>
<InkCLIInner
agent={agent}
initialSessionId={initialSessionId}
startupInfo={startupInfo}
soundService={soundService}
/>
</MouseProvider>
</KeypressProvider>
</ErrorBoundary>
);
}
/**
* Options for starting the Ink CLI
*/
export interface InkCLIOptions {
/** Update info if a newer version is available */
updateInfo?: { current: string; latest: string; updateCommand: string } | undefined;
/** True if installed agents differ from bundled and user should sync */
needsAgentSync?: boolean | undefined;
}
/**
* Start the modern Ink-based CLI
*/
export async function startInkCliRefactored(
agent: DextoAgent,
initialSessionId: string | null,
options: InkCLIOptions = {}
): Promise<void> {
registerGracefulShutdown(() => agent, { inkMode: true });
// Enable bracketed paste mode so we can detect pasted text
// This wraps pastes with escape sequences that our KeypressContext handles
enableBracketedPaste();
const baseStartupInfo = await getStartupInfo(agent);
const startupInfo = {
...baseStartupInfo,
updateInfo: options.updateInfo,
needsAgentSync: options.needsAgentSync,
};
// Initialize sound service from preferences
const { SoundNotificationService } = await import('./utils/soundNotification.js');
const { globalPreferencesExist, loadGlobalPreferences } = await import(
'@dexto/agent-management'
);
let soundService: SoundNotificationService | null = null;
// Initialize sound config with defaults (enabled by default even without preferences file)
let soundConfig = {
enabled: true,
onApprovalRequired: true,
onTaskComplete: true,
};
// Override with user preferences if they exist
if (globalPreferencesExist()) {
try {
const preferences = await loadGlobalPreferences();
soundConfig = {
enabled: preferences.sounds?.enabled ?? soundConfig.enabled,
onApprovalRequired:
preferences.sounds?.onApprovalRequired ?? soundConfig.onApprovalRequired,
onTaskComplete: preferences.sounds?.onTaskComplete ?? soundConfig.onTaskComplete,
};
} catch (error) {
// Log at debug level to help troubleshoot sound configuration issues
// Continue with default sounds - this is non-critical functionality
agent.logger.debug(
`Sound preferences could not be loaded: ${error instanceof Error ? error.message : String(error)}`
);
}
}
if (soundConfig.enabled) {
soundService = new SoundNotificationService(soundConfig);
}
const inkApp = render(
<InkCLIRefactored
agent={agent}
initialSessionId={initialSessionId}
startupInfo={startupInfo}
soundService={soundService}
/>,
{
exitOnCtrlC: false,
alternateBuffer: USE_ALTERNATE_BUFFER,
// Incremental rendering works better with VirtualizedList
// Static pattern doesn't need it (and may work better without)
incrementalRendering: USE_ALTERNATE_BUFFER,
}
);
await inkApp.waitUntilExit();
// Disable bracketed paste mode to restore normal terminal behavior
disableBracketedPaste();
}

View File

@@ -0,0 +1,462 @@
import React, {
forwardRef,
useState,
useImperativeHandle,
useRef,
useEffect,
useMemo,
} from 'react';
import { Box, Text } from 'ink';
import type { ToolDisplayData, ElicitationMetadata } from '@dexto/core';
import type { Key } from '../hooks/useInputOrchestrator.js';
import { ElicitationForm, type ElicitationFormHandle } from './ElicitationForm.js';
import { DiffPreview, CreateFilePreview } from './renderers/index.js';
import { isEditWriteTool } from '../utils/toolUtils.js';
import { formatToolHeader } from '../utils/messageFormatting.js';
export interface ApprovalRequest {
approvalId: string;
type: string;
sessionId?: string;
timeout?: number;
timestamp: Date;
metadata: Record<string, unknown>;
}
export interface ApprovalPromptHandle {
handleInput: (input: string, key: Key) => boolean;
}
/**
* Options passed when approving a request
*/
export interface ApprovalOptions {
/** Remember this tool for the entire session (approves ALL uses) */
rememberChoice?: boolean;
/** Remember a specific command pattern for bash (e.g., "git *") */
rememberPattern?: string;
/** Form data for elicitation requests */
formData?: Record<string, unknown>;
/** Enable "accept all edits" mode (auto-approve future edit_file/write_file) */
enableAcceptEditsMode?: boolean;
/** Remember directory access for the session */
rememberDirectory?: boolean;
}
interface ApprovalPromptProps {
approval: ApprovalRequest;
onApprove: (options: ApprovalOptions) => void;
onDeny: (feedback?: string) => void;
onCancel: () => void;
}
/**
* Selection option type - supports both simple yes/no and pattern-based options
*/
type SelectionOption =
| 'yes'
| 'yes-session'
| 'yes-accept-edits'
| 'no'
| `pattern-${number}`
// Plan review specific options
| 'plan-approve'
| 'plan-approve-accept-edits'
| 'plan-reject'; // Single reject option with feedback input
/**
* Compact approval prompt component that displays above the input area
* Shows options based on approval type:
* - Tool confirmation: Yes, Yes (Session), No
* - Bash with patterns: Yes (once), pattern options, Yes (all bash), No
* - Elicitation: Form with input fields
*/
export const ApprovalPrompt = forwardRef<ApprovalPromptHandle, ApprovalPromptProps>(
({ approval, onApprove, onDeny, onCancel }, ref) => {
const isCommandConfirmation = approval.type === 'command_confirmation';
const isElicitation = approval.type === 'elicitation';
const isDirectoryAccess = approval.type === 'directory_access';
// Extract tool metadata
const toolName = approval.metadata.toolName as string | undefined;
const toolArgs = (approval.metadata.args as Record<string, unknown>) || {};
// Check if this is a plan_review tool (shows custom approval options)
const isPlanReview =
toolName === 'custom--plan_review' ||
toolName === 'internal--plan_review' ||
toolName === 'plan_review';
// Extract suggested patterns for bash tools
const suggestedPatterns =
(approval.metadata.suggestedPatterns as string[] | undefined) ?? [];
const hasBashPatterns = suggestedPatterns.length > 0;
// Check if this is an edit/write file tool
const isEditOrWriteTool = isEditWriteTool(toolName);
// Format tool header using shared utility (same format as tool messages)
const formattedTool = useMemo(() => {
if (!toolName) return null;
return formatToolHeader(toolName, toolArgs);
}, [toolName, toolArgs]);
const [selectedIndex, setSelectedIndex] = useState(0);
// State for plan review feedback input
const [showFeedbackInput, setShowFeedbackInput] = useState(false);
const [feedbackText, setFeedbackText] = useState('');
// Ref for elicitation form
const elicitationFormRef = useRef<ElicitationFormHandle>(null);
// Use ref to avoid stale closure issues in handleInput
const selectedIndexRef = useRef(0);
// Build the list of options based on approval type
const options: Array<{ id: SelectionOption; label: string }> = [];
if (isPlanReview) {
// Plan review - show plan-specific options (2 options + feedback input)
options.push({ id: 'plan-approve', label: 'Approve' });
options.push({ id: 'plan-approve-accept-edits', label: 'Approve + Accept All Edits' });
// Third "option" is the feedback input (handled specially in render)
} else if (hasBashPatterns) {
// Bash tool with pattern suggestions
options.push({ id: 'yes', label: 'Yes (once)' });
suggestedPatterns.forEach((pattern, i) => {
options.push({
id: `pattern-${i}` as SelectionOption,
label: `Yes, allow "${pattern}"`,
});
});
options.push({ id: 'yes-session', label: 'Yes, allow all bash' });
options.push({ id: 'no', label: 'No' });
} else if (isCommandConfirmation) {
// Command confirmation (no session option)
options.push({ id: 'yes', label: 'Yes' });
options.push({ id: 'no', label: 'No' });
} else if (isDirectoryAccess) {
// Directory access - offer session-scoped access
const parentDir = approval.metadata.parentDir as string | undefined;
const dirLabel = parentDir ? ` "${parentDir}"` : '';
options.push({ id: 'yes', label: 'Yes (once)' });
options.push({ id: 'yes-session', label: `Yes, allow${dirLabel} (session)` });
options.push({ id: 'no', label: 'No' });
} else if (isEditOrWriteTool) {
// Edit/write file tools - offer "accept all edits" mode instead of session
options.push({ id: 'yes', label: 'Yes' });
options.push({ id: 'yes-accept-edits', label: 'Yes, and accept all edits' });
options.push({ id: 'no', label: 'No' });
} else {
// Standard tool confirmation
options.push({ id: 'yes', label: 'Yes' });
options.push({ id: 'yes-session', label: 'Yes (Session)' });
options.push({ id: 'no', label: 'No' });
}
// Keep ref in sync with state
useEffect(() => {
selectedIndexRef.current = selectedIndex;
}, [selectedIndex]);
// Helper to get the option at current index
const getCurrentOption = () => options[selectedIndexRef.current];
// Expose handleInput method via ref
useImperativeHandle(
ref,
() => ({
handleInput: (input: string, key: Key) => {
// For elicitation, delegate to the form
if (isElicitation && elicitationFormRef.current) {
return elicitationFormRef.current.handleInput(input, key);
}
// For plan review, calculate total options including feedback input
const totalOptions = isPlanReview ? options.length + 1 : options.length;
const isFeedbackSelected =
isPlanReview && selectedIndexRef.current === options.length;
// Handle typing when feedback input is selected
if (isFeedbackSelected) {
if (key.return) {
// Submit rejection with feedback
onDeny(feedbackText || undefined);
return true;
} else if (key.backspace || key.delete) {
setFeedbackText((prev) => prev.slice(0, -1));
return true;
} else if (key.upArrow) {
// Navigate up from feedback input
setSelectedIndex(options.length - 1);
return true;
} else if (key.downArrow) {
// Wrap to first option
setSelectedIndex(0);
return true;
} else if (key.escape) {
onCancel();
return true;
} else if (input && !key.ctrl && !key.meta) {
// Add typed character to feedback
setFeedbackText((prev) => prev + input);
return true;
}
return true; // Consume all input when feedback is selected
}
if (key.upArrow) {
setSelectedIndex((current) =>
current === 0 ? totalOptions - 1 : current - 1
);
return true;
} else if (key.downArrow) {
setSelectedIndex((current) =>
current === totalOptions - 1 ? 0 : current + 1
);
return true;
} else if (key.return) {
const option = getCurrentOption();
if (!option) return false;
// Plan review options
if (option.id === 'plan-approve') {
onApprove({});
} else if (option.id === 'plan-approve-accept-edits') {
onApprove({ enableAcceptEditsMode: true });
} else if (option.id === 'yes') {
onApprove({});
} else if (option.id === 'yes-session') {
// For directory access, remember the directory; otherwise remember the tool
if (isDirectoryAccess) {
onApprove({ rememberDirectory: true });
} else {
onApprove({ rememberChoice: true });
}
} else if (option.id === 'yes-accept-edits') {
// Approve and enable "accept all edits" mode
onApprove({ enableAcceptEditsMode: true });
} else if (option.id === 'no') {
onDeny();
} else if (option.id.startsWith('pattern-')) {
// Extract pattern index and get the pattern string
const patternIndex = parseInt(option.id.replace('pattern-', ''), 10);
const pattern = suggestedPatterns[patternIndex];
if (pattern) {
onApprove({ rememberPattern: pattern });
} else {
onApprove({});
}
}
return true;
} else if (key.shift && key.tab && isEditOrWriteTool) {
// Shift+Tab on edit/write tool: approve and enable "accept all edits" mode
onApprove({ enableAcceptEditsMode: true });
return true;
} else if (key.escape) {
onCancel();
return true;
}
return false;
},
}),
[
isElicitation,
isEditOrWriteTool,
isDirectoryAccess,
isPlanReview,
options,
suggestedPatterns,
onApprove,
onDeny,
onCancel,
feedbackText,
]
);
// For elicitation, render the form
if (isElicitation) {
const metadata = approval.metadata as unknown as ElicitationMetadata;
return (
<ElicitationForm
ref={elicitationFormRef}
metadata={metadata}
onSubmit={(formData) => onApprove({ formData })}
onCancel={onCancel}
/>
);
}
// Extract information from metadata based on approval type
const command = approval.metadata.command as string | undefined;
const displayPreview = approval.metadata.displayPreview as ToolDisplayData | undefined;
// Render preview based on display type
const renderPreview = () => {
if (!displayPreview) return null;
switch (displayPreview.type) {
case 'diff': {
const isOverwrite =
toolName === 'custom--write_file' ||
toolName === 'internal--write_file' ||
toolName === 'write_file';
return (
<DiffPreview
data={displayPreview}
headerType={isOverwrite ? 'overwrite' : 'edit'}
/>
);
}
case 'shell':
// For shell preview, just show the command (no output yet)
return (
<Box marginBottom={1} flexDirection="row">
<Text color="gray">$ </Text>
<Text color="yellowBright">{displayPreview.command}</Text>
{displayPreview.isBackground && <Text color="gray"> (background)</Text>}
</Box>
);
case 'file':
// Use enhanced file preview with full content for file creation
if (displayPreview.operation === 'create' && displayPreview.content) {
return <CreateFilePreview data={displayPreview} />;
}
// For plan_review (read operation with content), show full content for review
if (
displayPreview.operation === 'read' &&
displayPreview.content &&
isPlanReview
) {
return <CreateFilePreview data={displayPreview} header="Review plan" />;
}
// Fallback for other file operations
return (
<Box marginBottom={1}>
<Text color="gray">
{displayPreview.operation === 'read' &&
`Read ${displayPreview.lineCount ?? 'file'} lines`}
{displayPreview.operation === 'write' &&
`Write to ${displayPreview.path}`}
{displayPreview.operation === 'delete' &&
`Delete ${displayPreview.path}`}
</Text>
</Box>
);
default:
return null;
}
};
// Extract directory access metadata
const directoryPath = approval.metadata.path as string | undefined;
const parentDir = approval.metadata.parentDir as string | undefined;
const operation = approval.metadata.operation as string | undefined;
return (
<Box paddingX={0} paddingY={0} flexDirection="column">
{/* Compact header with context */}
<Box flexDirection="column" marginBottom={0}>
{isDirectoryAccess ? (
<>
<Box flexDirection="row">
<Text color="yellowBright" bold>
🔐 Directory Access:{' '}
</Text>
<Text color="cyan">{parentDir || directoryPath}</Text>
</Box>
<Box flexDirection="row" marginTop={0}>
<Text color="gray">{' '}</Text>
<Text color="gray">
{formattedTool ? `"${formattedTool.displayName}"` : 'Tool'}{' '}
wants to {operation || 'access'} files outside working directory
</Text>
</Box>
</>
) : (
<>
<Box flexDirection="row">
<Text color="yellowBright" bold>
🔐 Approval:{' '}
</Text>
{formattedTool && <Text color="cyan">{formattedTool.header}</Text>}
</Box>
{isCommandConfirmation && command && (
<Box flexDirection="row" marginTop={0}>
<Text color="gray">{' Command: '}</Text>
<Text color="red">{command}</Text>
</Box>
)}
</>
)}
</Box>
{/* Preview section - shown BEFORE approval options */}
{renderPreview()}
{/* Vertical selection options */}
<Box flexDirection="column" marginTop={0}>
{options.map((option, index) => {
const isSelected = index === selectedIndex;
const isNo = option.id === 'no';
return (
<Box key={option.id}>
{isSelected ? (
<Text color={isNo ? 'red' : 'green'} bold>
{' ▶ '}
{option.label}
</Text>
) : (
<Text color="gray">
{' '}
{option.label}
</Text>
)}
</Box>
);
})}
{/* Feedback input as third option for plan review */}
{isPlanReview && (
<Box>
{selectedIndex === options.length ? (
// Selected - show editable input
<Box flexDirection="row">
<Text color="red" bold>
{' ▶ '}
</Text>
{feedbackText ? (
<Text color="white">
{feedbackText}
<Text color="cyan"></Text>
</Text>
) : (
<Text color="gray">
What changes would you like?
<Text color="cyan"></Text>
</Text>
)}
</Box>
) : (
// Not selected - show placeholder
<Text color="gray">
{' '}
{feedbackText || 'What changes would you like?'}
</Text>
)}
</Box>
)}
</Box>
{/* Compact instructions */}
<Box marginTop={0}>
<Text color="gray">{' '} to select Enter to confirm Esc to cancel</Text>
</Box>
</Box>
);
}
);
ApprovalPrompt.displayName = 'ApprovalPrompt';

View File

@@ -0,0 +1,95 @@
import { useInput, Text } from 'ink';
interface CustomInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
placeholder?: string;
isProcessing?: boolean;
onWordDelete?: () => void;
onLineDelete?: () => void;
onToggleMultiLine?: () => void;
}
/**
* Custom input component that handles keyboard shortcuts
* Fully custom implementation without TextInput to properly handle shortcuts
*/
export default function CustomInput({
value,
onChange,
onSubmit,
placeholder,
isProcessing = false,
onWordDelete,
onLineDelete,
onToggleMultiLine,
}: CustomInputProps) {
// Handle all keyboard input directly
useInput(
(inputChar, key) => {
if (isProcessing) return;
// Shift+Enter = toggle multi-line mode
if (key.return && key.shift) {
onToggleMultiLine?.();
return;
}
// Enter = submit
if (key.return) {
onSubmit(value);
return;
}
// Ctrl+U = line delete (Unix standard, also what Cmd+Backspace becomes)
if (key.ctrl && inputChar === 'u') {
onLineDelete?.();
return;
}
// Ctrl+W = word delete (Unix standard, also what Option+Backspace becomes)
if (key.ctrl && inputChar === 'w') {
onWordDelete?.();
return;
}
// Regular backspace/delete
if (key.backspace || key.delete) {
onChange(value.slice(0, -1));
return;
}
// Regular character input
if (inputChar && !key.ctrl && !key.meta) {
onChange(value + inputChar);
}
},
{ isActive: true }
);
// Render with block cursor highlighting the character at cursor position
if (!value && placeholder) {
// Empty input - highlight first character of placeholder
const firstChar = placeholder[0] || ' ';
const rest = placeholder.slice(1);
return (
<Text>
<Text color="black" backgroundColor="green">
{firstChar}
</Text>
<Text color="gray">{rest}</Text>
</Text>
);
}
// Has value - highlight character after end (space)
return (
<Text>
{value}
<Text color="black" backgroundColor="green">
{' '}
</Text>
</Text>
);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { useInput } from 'ink';
import TextInput from 'ink-text-input';
interface CustomTextInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
placeholder?: string;
onWordDelete?: () => void;
onLineDelete?: () => void;
onNewline?: () => void;
}
/**
* Custom TextInput wrapper that handles keyboard shortcuts
* before TextInput consumes them
*/
export default function CustomTextInput({
value,
onChange,
onSubmit,
placeholder,
onWordDelete,
onLineDelete,
onNewline,
}: CustomTextInputProps) {
// Use useInput to intercept keyboard shortcuts
// This needs to run with isActive: true to intercept before TextInput
useInput(
(inputChar, key) => {
// Handle Shift+Enter or Ctrl+E to toggle multi-line mode
// Note: Shift+Enter may not work in all terminals, Ctrl+E is more reliable
if ((key.return && key.shift) || (key.ctrl && inputChar === 'e')) {
onNewline?.();
return;
}
// Handle word deletion (Cmd+Delete or Cmd+Backspace on Mac, Ctrl+Delete or Ctrl+Backspace on Windows/Linux)
// Note: On Mac, Cmd+Backspace is the standard for word deletion
if ((key.delete || key.backspace) && (key.meta || key.ctrl)) {
onWordDelete?.();
return;
}
// Handle line deletion (Cmd+Shift+Delete or Ctrl+U)
if ((key.delete && key.meta && key.shift) || (key.ctrl && inputChar === 'u')) {
onLineDelete?.();
return;
}
// Handle Ctrl+Shift+Delete as additional word deletion shortcut (Windows/Linux)
if (key.delete && key.ctrl && key.shift) {
onWordDelete?.();
return;
}
},
{ isActive: true }
);
return (
<TextInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
{...(placeholder ? { placeholder } : {})}
/>
);
}

View File

@@ -0,0 +1,193 @@
/**
* Editable multi-line input component
* Simple, reliable multi-line editor without complex box layouts
*/
import React, { useState, useEffect, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
interface EditableMultiLineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
placeholder?: string;
isProcessing?: boolean;
onToggleSingleLine?: () => void;
}
/**
* Multi-line input with cursor navigation
* Uses simple text rendering without nested boxes for reliable terminal display
*/
export default function EditableMultiLineInput({
value,
onChange,
onSubmit,
placeholder,
isProcessing = false,
onToggleSingleLine,
}: EditableMultiLineInputProps) {
const [cursorPos, setCursorPos] = useState(value.length);
// Keep cursor valid when value changes externally
useEffect(() => {
if (cursorPos > value.length) {
setCursorPos(value.length);
}
}, [value, cursorPos]);
// Calculate line info from cursor position
const { lines, currentLine, currentCol, lineStartIndices } = useMemo(() => {
const lines = value.split('\n');
const lineStartIndices: number[] = [];
let pos = 0;
for (const line of lines) {
lineStartIndices.push(pos);
pos += line.length + 1;
}
let currentLine = 0;
for (let i = 0; i < lineStartIndices.length; i++) {
const lineEnd =
i < lineStartIndices.length - 1 ? lineStartIndices[i + 1]! - 1 : value.length;
if (cursorPos <= lineEnd || i === lineStartIndices.length - 1) {
currentLine = i;
break;
}
}
const currentCol = cursorPos - (lineStartIndices[currentLine] ?? 0);
return { lines, currentLine, currentCol, lineStartIndices };
}, [value, cursorPos]);
useInput(
(inputChar, key) => {
if (isProcessing) return;
// Cmd/Ctrl+Enter = submit
if (key.return && (key.meta || key.ctrl)) {
onSubmit(value);
return;
}
// Shift+Enter = toggle back to single-line
// Note: Ctrl+E is reserved for standard "move to end of line" behavior
if (key.return && key.shift) {
onToggleSingleLine?.();
return;
}
// Enter = newline
if (key.return) {
const newValue = value.slice(0, cursorPos) + '\n' + value.slice(cursorPos);
onChange(newValue);
setCursorPos(cursorPos + 1);
return;
}
// Backspace
if (key.backspace && cursorPos > 0) {
onChange(value.slice(0, cursorPos - 1) + value.slice(cursorPos));
setCursorPos(cursorPos - 1);
return;
}
// Delete
if (key.delete && cursorPos < value.length) {
onChange(value.slice(0, cursorPos) + value.slice(cursorPos + 1));
return;
}
// Arrow navigation
if (key.leftArrow) {
setCursorPos(Math.max(0, cursorPos - 1));
return;
}
if (key.rightArrow) {
setCursorPos(Math.min(value.length, cursorPos + 1));
return;
}
if (key.upArrow && currentLine > 0) {
const prevLineStart = lineStartIndices[currentLine - 1]!;
const prevLineLen = lines[currentLine - 1]!.length;
setCursorPos(prevLineStart + Math.min(currentCol, prevLineLen));
return;
}
if (key.downArrow && currentLine < lines.length - 1) {
const nextLineStart = lineStartIndices[currentLine + 1]!;
const nextLineLen = lines[currentLine + 1]!.length;
setCursorPos(nextLineStart + Math.min(currentCol, nextLineLen));
return;
}
// Character input
if (inputChar && !key.ctrl && !key.meta) {
onChange(value.slice(0, cursorPos) + inputChar + value.slice(cursorPos));
setCursorPos(cursorPos + inputChar.length);
}
},
{ isActive: true }
);
// Render each line with cursor
const renderLine = (line: string, lineIdx: number) => {
const lineStart = lineStartIndices[lineIdx]!;
const isCursorLine = lineIdx === currentLine;
const cursorCol = isCursorLine ? cursorPos - lineStart : -1;
const prefix = lineIdx === 0 ? '» ' : ' ';
if (cursorCol < 0) {
// No cursor on this line
return (
<Text key={lineIdx}>
<Text color="cyan">{prefix}</Text>
<Text>{line || ' '}</Text>
</Text>
);
}
// Cursor on this line - highlight character at cursor
const before = line.slice(0, cursorCol);
const atCursor = line.charAt(cursorCol) || ' ';
const after = line.slice(cursorCol + 1);
return (
<Text key={lineIdx}>
<Text color="cyan">{prefix}</Text>
<Text>{before}</Text>
<Text backgroundColor="green" color="black">
{atCursor}
</Text>
<Text>{after}</Text>
</Text>
);
};
// Show placeholder if empty
if (!value && placeholder) {
return (
<Box flexDirection="column">
<Text>
<Text color="cyan">{'» '}</Text>
<Text backgroundColor="green" color="black">
{' '}
</Text>
<Text color="gray"> {placeholder}</Text>
</Text>
<Text color="gray">
Multi-line mode Cmd/Ctrl+Enter to submit Shift+Enter for single-line
</Text>
</Box>
);
}
return (
<Box flexDirection="column">
{lines.map((line, idx) => renderLine(line, idx))}
<Text color="gray">
Multi-line mode Cmd/Ctrl+Enter to submit Shift+Enter for single-line
</Text>
</Box>
);
}

View File

@@ -0,0 +1,557 @@
/**
* ElicitationForm Component
* Renders a form for ask_user/elicitation requests in the CLI
* Supports string, number, boolean, and enum field types
*/
import React, { useState, forwardRef, useImperativeHandle, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import type { Key } from '../hooks/useInputOrchestrator.js';
import type { ElicitationMetadata } from '@dexto/core';
export interface ElicitationFormHandle {
handleInput: (input: string, key: Key) => boolean;
}
interface ElicitationFormProps {
metadata: ElicitationMetadata;
onSubmit: (formData: Record<string, unknown>) => void;
onCancel: () => void;
}
interface FormField {
name: string;
label: string; // title if available, otherwise name
type: 'string' | 'number' | 'boolean' | 'enum' | 'array-enum';
description: string | undefined;
required: boolean;
enumValues: unknown[] | undefined;
}
/**
* Form component for elicitation/ask_user requests
*/
export const ElicitationForm = forwardRef<ElicitationFormHandle, ElicitationFormProps>(
({ metadata, onSubmit, onCancel }, ref) => {
// Parse schema into form fields
const fields = useMemo((): FormField[] => {
const schema = metadata.schema;
if (!schema?.properties) return [];
const required = schema.required || [];
return Object.entries(schema.properties)
.filter(
(entry): entry is [string, Exclude<(typeof entry)[1], boolean>] =>
typeof entry[1] !== 'boolean'
)
.map(([name, prop]) => {
let type: FormField['type'] = 'string';
let enumValues: unknown[] | undefined;
if (prop.type === 'boolean') {
type = 'boolean';
} else if (prop.type === 'number' || prop.type === 'integer') {
type = 'number';
} else if (prop.enum && Array.isArray(prop.enum)) {
type = 'enum';
enumValues = prop.enum;
} else if (
prop.type === 'array' &&
typeof prop.items === 'object' &&
prop.items &&
'enum' in prop.items
) {
type = 'array-enum';
enumValues = prop.items.enum as unknown[];
}
return {
name,
label: prop.title || name,
type,
description: prop.description,
required: required.includes(name),
enumValues,
};
});
}, [metadata.schema]);
// Form state
const [activeFieldIndex, setActiveFieldIndex] = useState(0);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [currentInput, setCurrentInput] = useState('');
const [enumIndex, setEnumIndex] = useState(0); // For enum selection
const [arraySelections, setArraySelections] = useState<Set<number>>(new Set()); // For array-enum
const [errors, setErrors] = useState<Record<string, string>>({});
const [isReviewing, setIsReviewing] = useState(false); // Confirmation step before submit
const activeField = fields[activeFieldIndex];
// Update a field value
const updateField = useCallback((name: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [name]: value }));
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}, []);
// Validate and enter review mode (or submit if already reviewing)
// Accepts optional currentFieldValue to handle async state update timing
const handleSubmit = useCallback(
(currentFieldValue?: { name: string; value: unknown }) => {
const newErrors: Record<string, string> = {};
// Merge current field value since React state update is async
const finalFormData = currentFieldValue
? { ...formData, [currentFieldValue.name]: currentFieldValue.value }
: formData;
for (const field of fields) {
if (field.required) {
const value = finalFormData[field.name];
if (value === undefined || value === null || value === '') {
newErrors[field.name] = 'Required';
}
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
// Focus first error field
const firstErrorField = fields.findIndex((f) => newErrors[f.name]);
if (firstErrorField >= 0) {
setActiveFieldIndex(firstErrorField);
}
return;
}
// Update formData with final value and enter review mode
if (currentFieldValue) {
setFormData(finalFormData);
}
setIsReviewing(true);
},
[fields, formData]
);
// Final submission after review
const confirmSubmit = useCallback(() => {
onSubmit(formData);
}, [formData, onSubmit]);
// Navigate to next/previous field
const nextField = useCallback(() => {
if (activeFieldIndex < fields.length - 1) {
// Save current input for string/number fields
if (activeField?.type === 'string' || activeField?.type === 'number') {
if (currentInput.trim()) {
const value =
activeField.type === 'number' ? Number(currentInput) : currentInput;
updateField(activeField.name, value);
}
}
setActiveFieldIndex((prev) => prev + 1);
setCurrentInput('');
setEnumIndex(0);
setArraySelections(new Set());
}
}, [activeFieldIndex, fields.length, activeField, currentInput, updateField]);
const prevField = useCallback(() => {
if (activeFieldIndex > 0) {
setActiveFieldIndex((prev) => prev - 1);
setCurrentInput('');
setEnumIndex(0);
setArraySelections(new Set());
}
}, [activeFieldIndex]);
// Handle keyboard input
useImperativeHandle(
ref,
() => ({
handleInput: (input: string, key: Key): boolean => {
// Review mode handling
if (isReviewing) {
if (key.return) {
confirmSubmit();
return true;
}
// Backspace to go back to editing
if (key.backspace || key.delete) {
setIsReviewing(false);
return true;
}
// Esc to cancel entirely
if (key.escape) {
onCancel();
return true;
}
return false;
}
// Escape to cancel
if (key.escape) {
onCancel();
return true;
}
if (!activeField) return false;
// Shift+Tab or Up to previous field (check BEFORE plain Tab)
if (
(key.tab && key.shift) ||
(key.upArrow &&
activeField.type !== 'enum' &&
activeField.type !== 'array-enum')
) {
prevField();
return true;
}
// Tab (without Shift) or Down to next field
if (
(key.tab && !key.shift) ||
(key.downArrow &&
activeField.type !== 'enum' &&
activeField.type !== 'array-enum')
) {
nextField();
return true;
}
// Field-specific handling
switch (activeField.type) {
case 'boolean': {
// Space or Enter to toggle
if (input === ' ' || key.return) {
const current = formData[activeField.name] === true;
const newValue = !current;
updateField(activeField.name, newValue);
if (key.return) {
if (activeFieldIndex === fields.length - 1) {
handleSubmit({ name: activeField.name, value: newValue });
} else {
nextField();
}
}
return true;
}
// Left/Right to toggle
if (key.leftArrow || key.rightArrow) {
const current = formData[activeField.name] === true;
updateField(activeField.name, !current);
return true;
}
break;
}
case 'enum': {
const values = activeField.enumValues || [];
// Up/Down to navigate enum
if (key.upArrow) {
setEnumIndex((prev) => (prev > 0 ? prev - 1 : values.length - 1));
return true;
}
if (key.downArrow) {
setEnumIndex((prev) => (prev < values.length - 1 ? prev + 1 : 0));
return true;
}
// Enter to select and move to next (or submit if last)
if (key.return) {
const selectedValue = values[enumIndex];
updateField(activeField.name, selectedValue);
if (activeFieldIndex === fields.length - 1) {
handleSubmit({ name: activeField.name, value: selectedValue });
} else {
nextField();
}
return true;
}
break;
}
case 'array-enum': {
const values = activeField.enumValues || [];
// Up/Down to navigate
if (key.upArrow) {
setEnumIndex((prev) => (prev > 0 ? prev - 1 : values.length - 1));
return true;
}
if (key.downArrow) {
setEnumIndex((prev) => (prev < values.length - 1 ? prev + 1 : 0));
return true;
}
// Space to toggle selection
if (input === ' ') {
setArraySelections((prev) => {
const next = new Set(prev);
if (next.has(enumIndex)) {
next.delete(enumIndex);
} else {
next.add(enumIndex);
}
// Update form data
const selected = Array.from(next).map((i) => values[i]);
updateField(activeField.name, selected);
return next;
});
return true;
}
// Enter to confirm and move to next (or submit if last)
if (key.return) {
// Get current selections for submit
const selected = Array.from(arraySelections).map((i) => values[i]);
if (activeFieldIndex === fields.length - 1) {
handleSubmit({ name: activeField.name, value: selected });
} else {
nextField();
}
return true;
}
break;
}
case 'string':
case 'number': {
// Enter to confirm field and move to next (or submit if last)
if (key.return) {
const value = currentInput.trim()
? activeField.type === 'number'
? Number(currentInput)
: currentInput
: formData[activeField.name]; // Use existing value if no new input
if (currentInput.trim()) {
updateField(activeField.name, value);
}
if (activeFieldIndex === fields.length - 1) {
// Last field - submit with current value
handleSubmit(
value !== undefined
? { name: activeField.name, value }
: undefined
);
} else {
nextField();
}
return true;
}
// Backspace
if (key.backspace || key.delete) {
setCurrentInput((prev) => prev.slice(0, -1));
return true;
}
// Regular character input
if (input && !key.ctrl && !key.meta) {
// For number type, only allow digits and decimal
if (activeField.type === 'number') {
if (/^[\d.-]$/.test(input)) {
setCurrentInput((prev) => prev + input);
}
} else {
setCurrentInput((prev) => prev + input);
}
return true;
}
break;
}
}
return false;
},
}),
[
activeField,
activeFieldIndex,
arraySelections,
confirmSubmit,
currentInput,
enumIndex,
fields.length,
formData,
handleSubmit,
isReviewing,
nextField,
onCancel,
prevField,
updateField,
]
);
if (fields.length === 0) {
return (
<Box flexDirection="column" paddingX={1}>
<Text color="red">Invalid form schema</Text>
</Box>
);
}
const prompt = metadata.prompt;
// Review mode - show summary of choices
if (isReviewing) {
return (
<Box flexDirection="column" paddingX={0} paddingY={0}>
<Box marginBottom={1}>
<Text color="green" bold>
Review your answers:
</Text>
</Box>
{fields.map((field) => {
const value = formData[field.name];
const displayValue = Array.isArray(value)
? value.join(', ')
: value === true
? 'Yes'
: value === false
? 'No'
: String(value ?? '');
return (
<Box key={field.name} marginBottom={0}>
<Text>
<Text color="cyan">{field.label}</Text>
<Text>: </Text>
<Text color="green">{displayValue}</Text>
</Text>
</Box>
);
})}
<Box marginTop={1}>
<Text color="gray">
Enter to submit Backspace to edit Esc to cancel
</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column" paddingX={0} paddingY={0}>
{/* Header */}
<Box marginBottom={1}>
<Text color="yellowBright" bold>
📝 {prompt}
</Text>
</Box>
{/* Form fields */}
{fields.map((field, index) => {
const isActive = index === activeFieldIndex;
const value = formData[field.name];
const error = errors[field.name];
return (
<Box key={field.name} flexDirection="column" marginBottom={1}>
{/* Field label */}
<Box>
<Text color={isActive ? 'cyan' : 'white'} bold={isActive}>
{isActive ? '▶ ' : ' '}
{field.label}
{field.required && <Text color="red">*</Text>}
{': '}
</Text>
{/* Field value display */}
{field.type === 'boolean' && (
<Text color={value === true ? 'green' : 'gray'}>
{value === true ? '[✓] Yes' : '[ ] No'}
{isActive && <Text color="gray"> (Space to toggle)</Text>}
</Text>
)}
{field.type === 'string' && !isActive && value !== undefined && (
<Text color="green">{String(value)}</Text>
)}
{field.type === 'number' && !isActive && value !== undefined && (
<Text color="green">{String(value)}</Text>
)}
{field.type === 'enum' && !isActive && value !== undefined && (
<Text color="green">{String(value)}</Text>
)}
{field.type === 'array-enum' &&
!isActive &&
Array.isArray(value) &&
value.length > 0 && (
<Text color="green">{value.join(', ')}</Text>
)}
</Box>
{/* Active field input */}
{isActive && (field.type === 'string' || field.type === 'number') && (
<Box marginLeft={2}>
<Text color="cyan">&gt; </Text>
<Text>{currentInput}</Text>
<Text color="cyan">_</Text>
</Box>
)}
{/* Enum selection */}
{isActive && field.type === 'enum' && field.enumValues && (
<Box flexDirection="column" marginLeft={2}>
{field.enumValues.map((opt, i) => (
<Box key={String(opt)}>
<Text color={i === enumIndex ? 'green' : 'gray'}>
{i === enumIndex ? ' ▶ ' : ' '}
{String(opt)}
</Text>
</Box>
))}
</Box>
)}
{/* Array-enum multi-select */}
{isActive && field.type === 'array-enum' && field.enumValues && (
<Box flexDirection="column" marginLeft={2}>
<Text color="gray"> (Space to select, Enter to confirm)</Text>
{field.enumValues.map((opt, i) => {
const isSelected = arraySelections.has(i);
return (
<Box key={String(opt)}>
<Text color={i === enumIndex ? 'cyan' : 'gray'}>
{i === enumIndex ? ' ▶ ' : ' '}
<Text color={isSelected ? 'green' : 'gray'}>
{isSelected ? '[✓]' : '[ ]'}
</Text>{' '}
{String(opt)}
</Text>
</Box>
);
})}
</Box>
)}
{/* Field description */}
{isActive && field.description && (
<Box marginLeft={2}>
<Text color="gray">{field.description}</Text>
</Box>
)}
{/* Error message */}
{error && (
<Box marginLeft={2}>
<Text color="red">{error}</Text>
</Box>
)}
</Box>
);
})}
{/* Help text */}
<Box marginTop={1}>
<Text color="gray">
Tab/ next field Shift+Tab/ prev Enter to confirm Esc to cancel
</Text>
</Box>
</Box>
);
}
);
ElicitationForm.displayName = 'ElicitationForm';

View File

@@ -0,0 +1,51 @@
/**
* Error Boundary Component
* Catches and displays errors in the component tree
*/
import React from 'react';
import { Box, Text } from 'ink';
import { logger } from '@dexto/core';
interface ErrorBoundaryProps {
children: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
logger.error(`Error in ErrorBoundary: ${error.message}`, {
error,
componentStack: errorInfo.componentStack,
});
}
override render(): React.ReactNode {
if (this.state.hasError) {
return (
<Box flexDirection="column" padding={1} borderStyle="round" borderColor="red">
<Text color="red" bold>
CLI Error
</Text>
<Text color="red">{this.state.error?.message || 'Unknown error'}</Text>
<Text color="yellowBright">Press Ctrl+C to exit</Text>
</Box>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,152 @@
/**
* Footer Component
* Status line at the bottom showing CWD, branch, and model info.
*/
import React, { useEffect, useState } from 'react';
import path from 'node:path';
import { Box, Text } from 'ink';
import { getModelDisplayName, type DextoAgent } from '@dexto/core';
interface FooterProps {
agent: DextoAgent;
sessionId: string | null;
modelName: string;
cwd?: string;
branchName?: string;
autoApproveEdits?: boolean;
planModeActive?: boolean;
/** Whether user is in shell command mode (input starts with !) */
isShellMode?: boolean;
}
function getDirectoryName(cwd: string): string {
const base = path.basename(cwd);
return base || cwd;
}
/**
* Pure presentational component for footer status line
*/
export function Footer({
agent,
sessionId,
modelName,
cwd,
branchName,
autoApproveEdits,
planModeActive,
isShellMode,
}: FooterProps) {
const displayPath = cwd ? getDirectoryName(cwd) : '';
const displayModelName = getModelDisplayName(modelName);
const [contextLeft, setContextLeft] = useState<{
percentLeft: number;
} | null>(null);
// Check if Dexto is actually the active provider (not just if API key exists)
const viaDexto = agent.getCurrentLLMConfig().provider === 'dexto';
useEffect(() => {
if (!sessionId) {
setContextLeft(null);
return;
}
let cancelled = false;
let refreshId = 0;
const refreshContext = async () => {
const requestId = ++refreshId;
try {
const stats = await agent.getContextStats(sessionId);
if (cancelled || requestId !== refreshId) return;
const percentLeft = Math.max(0, Math.min(100, 100 - stats.usagePercent));
setContextLeft({
percentLeft,
});
} catch {
if (!cancelled) {
setContextLeft(null);
}
}
};
refreshContext();
const bus = agent.agentEventBus;
const controller = new AbortController();
const { signal } = controller;
const sessionEvents = [
'llm:response',
'context:compacted',
'context:pruned',
'context:cleared',
'message:dequeued',
'session:reset',
] as const;
const handleEvent = (payload: { sessionId?: string }) => {
if (payload.sessionId && payload.sessionId !== sessionId) return;
refreshContext();
};
for (const eventName of sessionEvents) {
bus.on(eventName, handleEvent, { signal });
}
return () => {
cancelled = true;
controller.abort();
};
}, [agent, sessionId]);
// Shell mode changes the path color to yellow as indicator
const pathColor = isShellMode ? 'yellow' : 'blue';
return (
<Box flexDirection="column" paddingX={1}>
{/* Line 1: CWD (left) | Model name (right) */}
<Box flexDirection="row" justifyContent="space-between">
<Box>
<Text color={pathColor}>{displayPath}</Text>
{branchName && <Text color="gray"> ({branchName})</Text>}
</Box>
<Box>
<Text color="cyan">{displayModelName}</Text>
{viaDexto && <Text color="gray"> via Dexto</Text>}
</Box>
</Box>
{/* Line 2: Context left */}
{contextLeft && (
<Box>
<Text color="gray">{contextLeft.percentLeft}% context left</Text>
</Box>
)}
{/* Line 3: Mode indicators (left) */}
{/* Shift+Tab cycles: Normal → Plan Mode → Accept All Edits → Normal */}
{isShellMode && (
<Box>
<Text color="yellow" bold>
!
</Text>
<Text color="gray"> for shell mode</Text>
</Box>
)}
{planModeActive && !isShellMode && (
<Box>
<Text color="magentaBright">plan mode</Text>
<Text color="gray"> (shift + tab to cycle)</Text>
</Box>
)}
{autoApproveEdits && !planModeActive && !isShellMode && (
<Box>
<Text color="yellowBright">accept edits</Text>
<Text color="gray"> (shift + tab to cycle)</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,34 @@
/**
* HistorySearchBar - UI for Ctrl+R reverse history search
*
* Displayed at the very bottom when search mode is active.
*/
import React from 'react';
import { Box, Text } from 'ink';
interface HistorySearchBarProps {
/** Current search query */
query: string;
/** Whether there's a match for the current query */
hasMatch: boolean;
}
/**
* Search bar displayed during history search mode
*/
export function HistorySearchBar({ query, hasMatch }: HistorySearchBarProps) {
return (
<Box flexDirection="column" paddingX={1}>
{/* Hints on separate line above */}
<Text color="gray">Ctrl+R: older, Ctrl+E: newer, Enter: accept, Esc: cancel</Text>
{/* Search query line */}
<Box>
<Text color="green">search history: </Text>
<Text color="cyan">{query}</Text>
<Text color="gray">_</Text>
{query && !hasMatch && <Text color="red"> (no match)</Text>}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,62 @@
import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
interface MultiLineInputProps {
value: string;
placeholder?: string;
prompt?: string;
}
/**
* Custom multi-line input display component
* Calculates height based on content and displays text with proper line breaks
*/
export default function MultiLineInput({ value, placeholder, prompt = '> ' }: MultiLineInputProps) {
// Calculate number of lines (split by newlines and count)
const lines = useMemo(() => {
if (!value) return [];
return value.split('\n');
}, [value]);
const lineCount = lines.length;
// If empty, show placeholder
if (!value && placeholder) {
return (
<Box flexDirection="row">
<Text color="green" bold>
{prompt}
</Text>
<Text color="gray">{placeholder}</Text>
</Box>
);
}
// Display multi-line text - show last N lines if too many
const visibleLines = lineCount > 10 ? lines.slice(-10) : lines;
const startOffset = lineCount > 10 ? lineCount - 10 : 0;
return (
<Box flexDirection="column">
{startOffset > 0 && (
<Box>
<Text color="gray">... ({startOffset} more lines above)</Text>
</Box>
)}
{visibleLines.map((line, index) => {
const actualIndex = startOffset + index;
return (
<Box key={actualIndex} flexDirection="row">
{index === 0 && (
<Text color="green" bold>
{prompt}
</Text>
)}
{index > 0 && <Text color="green">{' '.repeat(prompt.length)}</Text>}
<Text wrap="wrap">{line || ' '}</Text>
</Box>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,376 @@
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
forwardRef,
useImperativeHandle,
} from 'react';
import { Box, Text } from 'ink';
import type { Key } from '../hooks/useInputOrchestrator.js';
import type { ResourceMetadata } from '@dexto/core';
import type { DextoAgent } from '@dexto/core';
export interface ResourceAutocompleteHandle {
handleInput: (input: string, key: Key) => boolean;
}
interface ResourceAutocompleteProps {
isVisible: boolean;
searchQuery: string;
onSelectResource: (resource: ResourceMetadata) => void;
onLoadIntoInput?: (text: string) => void; // New prop for Tab key
onClose: () => void;
agent: DextoAgent;
}
/**
* Get match score for resource: 0 = no match, 1 = description/URI match, 2 = name includes, 3 = name starts with
* Prioritizes name matches over description/URI matches
*/
function getResourceMatchScore(resource: ResourceMetadata, query: string): number {
if (!query) return 3; // Show all when no query
const lowerQuery = query.toLowerCase();
const name = (resource.name || '').toLowerCase();
const uri = resource.uri.toLowerCase();
const uriFilename = uri.split('/').pop()?.toLowerCase() || '';
const description = (resource.description || '').toLowerCase();
// Highest priority: name starts with query
if (name.startsWith(lowerQuery)) {
return 4;
}
// Second priority: name includes query
if (name.includes(lowerQuery)) {
return 3;
}
// Third priority: URI filename starts with query
if (uriFilename.startsWith(lowerQuery)) {
return 2;
}
// Fourth priority: URI filename includes query
if (uriFilename.includes(lowerQuery)) {
return 2;
}
// Fifth priority: URI includes query
if (uri.includes(lowerQuery)) {
return 1;
}
// Lowest priority: description includes query
if (description.includes(lowerQuery)) {
return 1;
}
return 0; // No match
}
/**
* Check if resource matches query (for filtering)
*/
function matchesQuery(resource: ResourceMetadata, query: string): boolean {
return getResourceMatchScore(resource, query) > 0;
}
/**
* Sort resources by match score (highest first), then alphabetically
*/
function sortResources(resources: ResourceMetadata[], query: string): ResourceMetadata[] {
if (!query) return resources;
const lowerQuery = query.toLowerCase();
return [...resources].sort((a, b) => {
const scoreA = getResourceMatchScore(a, lowerQuery);
const scoreB = getResourceMatchScore(b, lowerQuery);
// Sort by score first (higher score first)
if (scoreA !== scoreB) {
return scoreB - scoreA;
}
// If scores are equal, sort alphabetically by name
const aName = (a.name || '').toLowerCase();
const bName = (b.name || '').toLowerCase();
return aName.localeCompare(bName);
});
}
/**
* Inner component - wrapped with React.memo below
*/
const ResourceAutocompleteInner = forwardRef<ResourceAutocompleteHandle, ResourceAutocompleteProps>(
function ResourceAutocomplete(
{ isVisible, searchQuery, onSelectResource, onLoadIntoInput, onClose, agent },
ref
) {
const [resources, setResources] = useState<ResourceMetadata[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Combined state to guarantee single render on navigation
const [selection, setSelection] = useState({ index: 0, offset: 0 });
const selectedIndexRef = useRef(0);
const MAX_VISIBLE_ITEMS = 5;
// Update selection AND scroll offset in a single state update
// This guarantees exactly one render per navigation action
const updateSelection = useCallback(
(indexUpdater: number | ((prev: number) => number)) => {
setSelection((prev) => {
const newIndex =
typeof indexUpdater === 'function'
? indexUpdater(prev.index)
: indexUpdater;
selectedIndexRef.current = newIndex;
// Calculate new scroll offset
let newOffset = prev.offset;
if (newIndex < prev.offset) {
newOffset = newIndex;
} else if (newIndex >= prev.offset + MAX_VISIBLE_ITEMS) {
newOffset = Math.max(0, newIndex - MAX_VISIBLE_ITEMS + 1);
}
return { index: newIndex, offset: newOffset };
});
},
[MAX_VISIBLE_ITEMS]
);
// Fetch resources from agent
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
setIsLoading(true);
const fetchResources = async () => {
try {
const resourceSet = await agent.listResources();
const resourceList: ResourceMetadata[] = Object.values(resourceSet);
if (!cancelled) {
setResources(resourceList);
setIsLoading(false);
}
} catch {
if (!cancelled) {
// Silently fail - don't use console.error as it interferes with Ink rendering
setResources([]);
setIsLoading(false);
}
}
};
void fetchResources();
return () => {
cancelled = true;
};
}, [isVisible, agent]);
// NOTE: Auto-close logic is handled synchronously in TextBufferInput.tsx
// (on backspace deleting @ and on space after @). We don't use useEffect here
// because React batches state updates, causing race conditions where isVisible
// and searchQuery update at different times.
// Extract query from @mention (everything after @)
const mentionQuery = useMemo(() => {
// Find the last @ that's at start or after space
const atIndex = searchQuery.lastIndexOf('@');
if (atIndex === -1) return '';
// Check if @ is at start or preceded by space
const prevChar = searchQuery[atIndex - 1];
if (atIndex === 0 || (prevChar && /\s/.test(prevChar))) {
return searchQuery.slice(atIndex + 1).trim();
}
return '';
}, [searchQuery]);
// Filter and sort resources (no limit - scrolling handles it)
const filteredResources = useMemo(() => {
const matched = resources.filter((r) => matchesQuery(r, mentionQuery));
return sortResources(matched, mentionQuery);
}, [resources, mentionQuery]);
// Track items length for reset detection
const prevItemsLengthRef = useRef(filteredResources.length);
const itemsChanged = filteredResources.length !== prevItemsLengthRef.current;
// Derive clamped selection values during render (always valid, no setState needed)
// This prevents the double-render that was causing flickering
const selectedIndex = itemsChanged
? 0
: Math.min(selection.index, Math.max(0, filteredResources.length - 1));
const scrollOffset = itemsChanged
? 0
: Math.min(selection.offset, Math.max(0, filteredResources.length - MAX_VISIBLE_ITEMS));
// Sync state only when items actually changed AND state differs
// This effect runs AFTER render, updating state for next user interaction
useEffect(() => {
if (itemsChanged) {
prevItemsLengthRef.current = filteredResources.length;
// Only setState if values actually differ (prevents unnecessary re-render)
if (selection.index !== 0 || selection.offset !== 0) {
selectedIndexRef.current = 0;
setSelection({ index: 0, offset: 0 });
} else {
selectedIndexRef.current = 0;
}
}
}, [itemsChanged, filteredResources.length, selection.index, selection.offset]);
// Calculate visible items based on scroll offset
const visibleResources = useMemo(() => {
return filteredResources.slice(scrollOffset, scrollOffset + MAX_VISIBLE_ITEMS);
}, [filteredResources, scrollOffset, MAX_VISIBLE_ITEMS]);
// Expose handleInput method via ref
useImperativeHandle(
ref,
() => ({
handleInput: (_input: string, key: Key): boolean => {
if (!isVisible) return false;
// Escape always closes, regardless of item count
if (key.escape) {
onClose();
return true;
}
const itemsLength = filteredResources.length;
if (itemsLength === 0) return false;
if (key.upArrow) {
updateSelection((prev) => (prev - 1 + itemsLength) % itemsLength);
return true;
}
if (key.downArrow) {
updateSelection((prev) => (prev + 1) % itemsLength);
return true;
}
// Tab to load into input (for editing before selection)
if (key.tab) {
const resource = filteredResources[selectedIndexRef.current];
if (!resource) return false;
// Get the @ position and construct the text to load
const atIndex = searchQuery.lastIndexOf('@');
if (atIndex >= 0) {
const before = searchQuery.slice(0, atIndex + 1);
const uriParts = resource.uri.split('/');
const reference =
resource.name || uriParts[uriParts.length - 1] || resource.uri;
onLoadIntoInput?.(`${before}${reference}`);
} else {
// Fallback: just append @resource
const uriParts = resource.uri.split('/');
const reference =
resource.name || uriParts[uriParts.length - 1] || resource.uri;
onLoadIntoInput?.(`${searchQuery}@${reference}`);
}
return true;
}
// Enter to select
if (key.return) {
const resource = filteredResources[selectedIndexRef.current];
if (resource) {
onSelectResource(resource);
return true;
}
}
// Don't consume other keys (typing, backspace, etc.)
return false;
},
}),
[
isVisible,
filteredResources,
selectedIndexRef,
searchQuery,
onClose,
onLoadIntoInput,
onSelectResource,
updateSelection,
]
);
if (!isVisible) return null;
if (isLoading) {
return (
<Box paddingX={0} paddingY={0}>
<Text color="gray">Loading resources...</Text>
</Box>
);
}
if (filteredResources.length === 0) {
return (
<Box paddingX={0} paddingY={0}>
<Text color="gray">
{mentionQuery
? `No resources match "${mentionQuery}"`
: 'No resources available. Connect an MCP server or enable internal resources.'}
</Text>
</Box>
);
}
const totalItems = filteredResources.length;
return (
<Box flexDirection="column">
<Box paddingX={0} paddingY={0}>
<Text color="yellowBright" bold>
Resources ({selectedIndex + 1}/{totalItems}) - navigate, Tab load, Enter
select, Esc close
</Text>
</Box>
{visibleResources.map((resource, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const uriParts = resource.uri.split('/');
const displayName =
resource.name || uriParts[uriParts.length - 1] || resource.uri;
const isImage = (resource.mimeType || '').startsWith('image/');
// Truncate URI for display (show last 40 chars with ellipsis)
const truncatedUri =
resource.uri.length > 50 ? '…' + resource.uri.slice(-49) : resource.uri;
return (
<Box key={resource.uri}>
{isImage && <Text color={isSelected ? 'cyan' : 'gray'}>🖼 </Text>}
<Text color={isSelected ? 'cyan' : 'white'} bold={isSelected}>
{displayName}
</Text>
{resource.serverName && (
<Text color="gray"> [{resource.serverName}]</Text>
)}
<Text color="gray"> {truncatedUri}</Text>
</Box>
);
})}
</Box>
);
}
);
/**
* Export with React.memo to prevent unnecessary re-renders from parent
* Only re-renders when props actually change (shallow comparison)
*/
const ResourceAutocomplete = React.memo(
ResourceAutocompleteInner
) as typeof ResourceAutocompleteInner;
export default ResourceAutocomplete;

View File

@@ -0,0 +1,589 @@
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
forwardRef,
useImperativeHandle,
} from 'react';
import { Box, Text, useStdout } from 'ink';
import type { Key } from '../hooks/useInputOrchestrator.js';
import type { PromptInfo } from '@dexto/core';
import type { DextoAgent } from '@dexto/core';
import { getAllCommands } from '../../commands/interactive-commands/commands.js';
import type { CommandDefinition } from '../../commands/interactive-commands/command-parser.js';
export interface SlashCommandAutocompleteHandle {
handleInput: (input: string, key: Key) => boolean;
}
interface SlashCommandAutocompleteProps {
isVisible: boolean;
searchQuery: string;
onSelectPrompt: (prompt: PromptInfo) => void;
onSelectSystemCommand?: (command: string) => void;
onLoadIntoInput?: (command: string) => void; // For Tab - loads command into input
onSubmitRaw?: ((text: string) => Promise<void> | void) | undefined; // For Enter with no matches - submit raw text
onClose: () => void;
agent: DextoAgent;
}
interface PromptItem extends PromptInfo {
kind: 'prompt';
}
interface SystemCommandItem {
kind: 'system';
name: string;
description: string;
category?: string;
aliases?: string[];
}
/**
* Get match score for prompt: 0 = no match, 1 = description/title match, 2 = name includes, 3 = name starts with
*/
function getPromptMatchScore(prompt: PromptInfo, query: string): number {
if (!query) return 3; // Show all when no query
const lowerQuery = query.toLowerCase();
const name = prompt.name.toLowerCase();
const description = (prompt.description || '').toLowerCase();
const title = (prompt.title || '').toLowerCase();
// Highest priority: name starts with query
if (name.startsWith(lowerQuery)) {
return 3;
}
// Second priority: name includes query
if (name.includes(lowerQuery)) {
return 2;
}
// Lowest priority: description or title includes query
if (description.includes(lowerQuery) || title.includes(lowerQuery)) {
return 1;
}
return 0; // No match
}
/**
* Check if prompt matches query (for filtering)
*/
function matchesPromptQuery(prompt: PromptInfo, query: string): boolean {
return getPromptMatchScore(prompt, query) > 0;
}
type CommandMatchCandidate = Pick<CommandDefinition, 'name' | 'description' | 'aliases'>;
/**
* Simple fuzzy match - checks if query matches system command name or description
* Returns a score: 0 = no match, 1 = description match, 2 = alias match, 3 = name includes, 4 = name starts with
*/
function getSystemCommandMatchScore(cmd: CommandMatchCandidate, query: string): number {
if (!query) return 4; // Show all when no query
const lowerQuery = query.toLowerCase();
const name = cmd.name.toLowerCase();
const description = (cmd.description || '').toLowerCase();
// Highest priority: name starts with query
if (name.startsWith(lowerQuery)) {
return 4;
}
// Second priority: name includes query
if (name.includes(lowerQuery)) {
return 3;
}
// Third priority: aliases match
if (cmd.aliases) {
for (const alias of cmd.aliases) {
const lowerAlias = alias.toLowerCase();
if (lowerAlias.startsWith(lowerQuery)) {
return 2;
}
if (lowerAlias.includes(lowerQuery)) {
return 2;
}
}
}
// Lowest priority: description includes query
if (description.includes(lowerQuery)) {
return 1;
}
return 0; // No match
}
/**
* Check if command matches query (for filtering)
*/
function matchesSystemCommandQuery(cmd: CommandMatchCandidate, query: string): boolean {
return getSystemCommandMatchScore(cmd, query) > 0;
}
/**
* Truncate text to fit within maxLength, adding ellipsis if truncated
*/
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
if (maxLength <= 3) return text.slice(0, maxLength);
return text.slice(0, maxLength - 1) + '…';
}
/**
* Inner component - wrapped with React.memo below
*/
const SlashCommandAutocompleteInner = forwardRef<
SlashCommandAutocompleteHandle,
SlashCommandAutocompleteProps
>(function SlashCommandAutocomplete(
{
isVisible,
searchQuery,
onSelectPrompt,
onSelectSystemCommand,
onLoadIntoInput,
onSubmitRaw,
onClose,
agent,
},
ref
) {
const [prompts, setPrompts] = useState<PromptItem[]>([]);
const [systemCommands, setSystemCommands] = useState<SystemCommandItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Combined state to guarantee single render on navigation
const [selection, setSelection] = useState({ index: 0, offset: 0 });
const selectedIndexRef = useRef(0);
const { stdout } = useStdout();
const terminalWidth = stdout?.columns || 80;
const MAX_VISIBLE_ITEMS = 8;
// Update selection AND scroll offset in a single state update
// This guarantees exactly one render per navigation action
const updateSelection = useCallback(
(indexUpdater: number | ((prev: number) => number)) => {
setSelection((prev) => {
const newIndex =
typeof indexUpdater === 'function' ? indexUpdater(prev.index) : indexUpdater;
selectedIndexRef.current = newIndex;
// Calculate new scroll offset
let newOffset = prev.offset;
if (newIndex < prev.offset) {
newOffset = newIndex;
} else if (newIndex >= prev.offset + MAX_VISIBLE_ITEMS) {
newOffset = Math.max(0, newIndex - MAX_VISIBLE_ITEMS + 1);
}
return { index: newIndex, offset: newOffset };
});
},
[MAX_VISIBLE_ITEMS]
);
// Fetch prompts and system commands from agent
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
setIsLoading(true);
const fetchCommands = async () => {
try {
// Fetch prompts
const promptSet = await agent.listPrompts();
const promptList: PromptItem[] = Object.values(promptSet).map((p) => ({
...p,
kind: 'prompt' as const,
}));
// Fetch system commands
const allCommands = getAllCommands();
const commandList: SystemCommandItem[] = allCommands.map((cmd) => ({
kind: 'system' as const,
name: cmd.name,
description: cmd.description,
...(cmd.category && { category: cmd.category }),
...(cmd.aliases && { aliases: cmd.aliases }),
}));
if (!cancelled) {
setPrompts(promptList);
setSystemCommands(commandList);
setIsLoading(false);
}
} catch {
if (!cancelled) {
// Silently fail - don't use console.error as it interferes with Ink rendering
setPrompts([]);
setSystemCommands([]);
setIsLoading(false);
}
}
};
void fetchCommands();
return () => {
cancelled = true;
};
}, [isVisible, agent]);
// Extract command name from search query (only the first word after /)
const commandQuery = useMemo(() => {
if (!searchQuery.startsWith('/')) return '';
const afterSlash = searchQuery.slice(1).trim();
// Only take the first word (command name), not the arguments
const spaceIndex = afterSlash.indexOf(' ');
return spaceIndex > 0 ? afterSlash.slice(0, spaceIndex) : afterSlash;
}, [searchQuery]);
// Filter prompts and system commands based on query
const filteredPrompts = useMemo(() => {
if (!commandQuery) {
return prompts;
}
// Filter and sort by match score (highest first)
return prompts
.filter((p) => matchesPromptQuery(p, commandQuery))
.sort((a, b) => {
const scoreA = getPromptMatchScore(a, commandQuery);
const scoreB = getPromptMatchScore(b, commandQuery);
return scoreB - scoreA; // Higher score first
});
}, [prompts, commandQuery]);
const filteredSystemCommands = useMemo(() => {
if (!commandQuery) {
return systemCommands;
}
// Filter and sort by match score (highest first)
return systemCommands
.filter((cmd) => matchesSystemCommandQuery(cmd, commandQuery))
.sort((a, b) => {
const scoreA = getSystemCommandMatchScore(a, commandQuery);
const scoreB = getSystemCommandMatchScore(b, commandQuery);
return scoreB - scoreA; // Higher score first
});
}, [systemCommands, commandQuery]);
// Check if user has started typing arguments (hide autocomplete)
const hasArguments = useMemo(() => {
if (!searchQuery.startsWith('/')) return false;
const afterSlash = searchQuery.slice(1).trim();
return afterSlash.includes(' ');
}, [searchQuery]);
// Combine items: system commands first, then prompts
// When typing arguments, show only exact matches (for argument hints)
const combinedItems = useMemo(() => {
const items: Array<
{ kind: 'system'; command: SystemCommandItem } | { kind: 'prompt'; prompt: PromptItem }
> = [];
if (hasArguments) {
// When typing arguments, only show exact match for the command
const exactSystemCmd = systemCommands.find(
(cmd) => cmd.name.toLowerCase() === commandQuery.toLowerCase()
);
if (exactSystemCmd) {
items.push({ kind: 'system', command: exactSystemCmd });
}
const exactPrompt = prompts.find(
(p) =>
p.name.toLowerCase() === commandQuery.toLowerCase() ||
(p.displayName && p.displayName.toLowerCase() === commandQuery.toLowerCase())
);
if (exactPrompt) {
items.push({ kind: 'prompt', prompt: exactPrompt });
}
return items;
}
// System commands first (they're more commonly used)
filteredSystemCommands.forEach((cmd) => items.push({ kind: 'system', command: cmd }));
// Then prompts
filteredPrompts.forEach((prompt) => items.push({ kind: 'prompt', prompt }));
return items;
}, [
hasArguments,
filteredPrompts,
filteredSystemCommands,
systemCommands,
prompts,
commandQuery,
]);
// Get stable identity for first item (used to detect content changes)
const getFirstItemId = (): string | null => {
const first = combinedItems[0];
if (!first) return null;
if (first.kind === 'system') return `sys:${first.command.name}`;
return `prompt:${first.prompt.name}`;
};
// Track items for reset detection (length + first item identity)
const currentFirstId = getFirstItemId();
const prevItemsRef = useRef({ length: combinedItems.length, firstId: currentFirstId });
const itemsChanged =
combinedItems.length !== prevItemsRef.current.length ||
currentFirstId !== prevItemsRef.current.firstId;
// Derive clamped selection values during render (always valid, no setState needed)
// This prevents the double-render that was causing flickering
const selectedIndex = itemsChanged
? 0
: Math.min(selection.index, Math.max(0, combinedItems.length - 1));
const scrollOffset = itemsChanged
? 0
: Math.min(selection.offset, Math.max(0, combinedItems.length - MAX_VISIBLE_ITEMS));
// Sync state only when items actually changed AND state differs
// This effect runs AFTER render, updating state for next user interaction
useEffect(() => {
if (itemsChanged) {
prevItemsRef.current = { length: combinedItems.length, firstId: currentFirstId };
// Only setState if values actually differ (prevents unnecessary re-render)
if (selection.index !== 0 || selection.offset !== 0) {
selectedIndexRef.current = 0;
setSelection({ index: 0, offset: 0 });
} else {
selectedIndexRef.current = 0;
}
}
}, [itemsChanged, combinedItems.length, currentFirstId, selection.index, selection.offset]);
// Calculate visible items based on scroll offset
const visibleItems = useMemo(() => {
return combinedItems.slice(scrollOffset, scrollOffset + MAX_VISIBLE_ITEMS);
}, [combinedItems, scrollOffset, MAX_VISIBLE_ITEMS]);
// Expose handleInput method via ref
useImperativeHandle(
ref,
() => ({
handleInput: (input: string, key: Key): boolean => {
if (!isVisible) return false;
// Escape always closes, regardless of item count
if (key.escape) {
onClose();
return true;
}
const itemsLength = combinedItems.length;
// Handle Enter when no matches or typing arguments
// Submit raw text directly (main input won't handle it since overlay is active)
if (itemsLength === 0 || hasArguments) {
if (key.return) {
void Promise.resolve(onSubmitRaw?.(searchQuery)).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
agent.logger.error(
`SlashCommandAutocomplete: Failed to submit raw command: ${message}`
);
});
onClose();
return true;
}
// Let other keys (typing, backspace) fall through
return false;
}
if (key.upArrow) {
updateSelection((prev) => (prev - 1 + itemsLength) % itemsLength);
return true;
}
if (key.downArrow) {
updateSelection((prev) => (prev + 1) % itemsLength);
return true;
}
// Tab: For interactive commands (model, resume, switch), execute them like Enter
// For other commands, load into input for editing
if (key.tab) {
const item = combinedItems[selectedIndexRef.current];
if (!item) return false;
// Check if this is an interactive command that should be executed
const interactiveCommands = ['model', 'resume', 'switch'];
const isInteractiveCommand =
item.kind === 'system' && interactiveCommands.includes(item.command.name);
if (isInteractiveCommand && item.kind === 'system') {
// Execute interactive command (same as Enter)
onSelectSystemCommand?.(item.command.name);
} else if (item.kind === 'system') {
// Load system command into input
onLoadIntoInput?.(`/${item.command.name}`);
} else {
// Load prompt command into input using pre-computed commandName
// commandName is collision-resolved by PromptManager (e.g., "plan" or "config:plan")
const cmdName =
item.prompt.commandName || item.prompt.displayName || item.prompt.name;
const argsString =
item.prompt.arguments && item.prompt.arguments.length > 0
? ' ' +
item.prompt.arguments
.map((arg) => `<${arg.name}${arg.required ? '' : '?'}>`)
.join(' ')
: '';
onLoadIntoInput?.(`/${cmdName}${argsString}`);
}
return true;
}
// Enter: Execute the highlighted command/prompt and close overlay
if (key.return) {
const item = combinedItems[selectedIndexRef.current];
if (!item) return false;
if (item.kind === 'system') {
onSelectSystemCommand?.(item.command.name);
} else {
onSelectPrompt(item.prompt);
}
onClose();
return true;
}
// Don't consume other keys (typing, backspace, etc.)
// Let them fall through to the input handler
return false;
},
}),
[
isVisible,
combinedItems,
hasArguments,
selectedIndexRef,
searchQuery,
onClose,
onLoadIntoInput,
onSubmitRaw,
onSelectPrompt,
onSelectSystemCommand,
updateSelection,
agent,
]
);
if (!isVisible) return null;
// Show loading state while fetching commands
if (isLoading) {
return (
<Box width={terminalWidth} paddingX={0} paddingY={0}>
<Text color="gray">Loading commands...</Text>
</Box>
);
}
// If no items after loading, don't render
if (combinedItems.length === 0) {
return null;
}
const totalItems = combinedItems.length;
// Show simplified header when user is typing arguments
const headerText = hasArguments
? 'Press Enter to execute'
: `Commands (${selectedIndex + 1}/${totalItems}) - ↑↓ navigate, Tab load, Enter execute, Esc close`;
return (
<Box flexDirection="column" width={terminalWidth}>
<Box paddingX={0} paddingY={0}>
<Text color="purple" bold>
{headerText}
</Text>
</Box>
{visibleItems.map((item, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
if (item.kind === 'system') {
const cmd = item.command;
const nameText = `/${cmd.name}`;
const categoryText = cmd.category ? ` (${cmd.category})` : '';
const descText = cmd.description || '';
// Two-line layout:
// Line 1: /command-name
// Line 2: Description text (category)
return (
<Box key={`system-${cmd.name}`} flexDirection="column" paddingX={0}>
<Text color={isSelected ? 'cyan' : 'gray'} bold={isSelected}>
{nameText}
</Text>
<Text color={isSelected ? 'white' : 'gray'}>
{' '}
{descText}
{categoryText}
</Text>
</Box>
);
}
// Prompt command (MCP prompts)
const prompt = item.prompt;
// Use displayName for user-friendly display, fall back to full name
const displayName = prompt.displayName || prompt.name;
// Check if there's a collision (commandName includes source prefix)
const hasCollision = prompt.commandName && prompt.commandName !== displayName;
const nameText = `/${displayName}`;
const argsString =
prompt.arguments && prompt.arguments.length > 0
? ' ' +
prompt.arguments
.map((arg) => `<${arg.name}${arg.required ? '' : '?'}>`)
.join(' ')
: '';
const description = prompt.title || prompt.description || '';
// Two-line layout:
// Line 1: /command-name <args>
// Line 2: Description text (source)
const commandText = nameText + argsString;
// Show source as label, with collision indicator if needed
// For plugin skills, show namespace (plugin name) instead of "config"
const metadata = prompt.metadata as Record<string, unknown> | undefined;
const displaySource = metadata?.namespace
? String(metadata.namespace)
: prompt.source || 'prompt';
const sourceLabel = hasCollision
? `${displaySource} - use /${prompt.commandName}`
: displaySource;
return (
<Box key={`prompt-${prompt.name}`} flexDirection="column" paddingX={0}>
<Text color={isSelected ? 'cyan' : 'gray'} bold={isSelected}>
{commandText}
</Text>
<Text color={isSelected ? 'white' : 'gray'}>
{' '}
{description}
{` (${sourceLabel})`}
</Text>
</Box>
);
})}
</Box>
);
});
/**
* Export with React.memo to prevent unnecessary re-renders from parent
* Only re-renders when props actually change (shallow comparison)
*/
export const SlashCommandAutocomplete = React.memo(
SlashCommandAutocompleteInner
) as typeof SlashCommandAutocompleteInner;

View File

@@ -0,0 +1,176 @@
/**
* StatusBar Component
* Displays processing status and controls above the input area
*
* Layout:
* - Line 1: Spinner + phrase (+ queue count if any)
* - Line 2: Meta info (time, tokens, cancel hint)
* This 2-line layout prevents truncation on any terminal width.
*/
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { DextoAgent } from '@dexto/core';
import { usePhraseCycler } from '../hooks/usePhraseCycler.js';
import { useElapsedTime } from '../hooks/useElapsedTime.js';
import { useTokenCounter } from '../hooks/useTokenCounter.js';
interface StatusBarProps {
agent: DextoAgent;
isProcessing: boolean;
isThinking: boolean;
isCompacting: boolean;
approvalQueueCount: number;
copyModeEnabled?: boolean;
/** Whether an approval prompt is currently shown */
isAwaitingApproval?: boolean;
/** Whether the todo list is expanded */
todoExpanded?: boolean;
/** Whether there are todos to display */
hasTodos?: boolean;
/** Whether plan mode is active */
planModeActive?: boolean;
/** Whether accept all edits mode is active */
autoApproveEdits?: boolean;
}
/**
* Status bar that shows processing state above input area
* Provides clear feedback on whether the agent is running or idle
*
* Design decisions:
* - Hide spinner during approval wait (user is reviewing, not waiting)
* - Only show elapsed time after 30s (avoid visual noise for fast operations)
*/
export function StatusBar({
agent,
isProcessing,
isThinking,
isCompacting,
approvalQueueCount,
copyModeEnabled = false,
isAwaitingApproval = false,
todoExpanded = true,
hasTodos = false,
planModeActive = false,
autoApproveEdits = false,
}: StatusBarProps) {
// Cycle through witty phrases while processing (not during compacting)
const { phrase } = usePhraseCycler({ isActive: isProcessing && !isCompacting });
// Track elapsed time during processing
const { formatted: elapsedTime, elapsedMs } = useElapsedTime({ isActive: isProcessing });
// Track token usage during processing
const { formatted: tokenCount } = useTokenCounter({ agent, isActive: isProcessing });
// Only show time after 30 seconds
const showTime = elapsedMs >= 30000;
// Show copy mode warning (highest priority)
if (copyModeEnabled) {
return (
<Box paddingX={1} marginBottom={0}>
<Text color="yellowBright" bold>
📋 Copy Mode - Select text with mouse. Press any key to exit.
</Text>
</Box>
);
}
if (!isProcessing) {
// Mode indicators (plan mode, accept edits) are shown in Footer, not here
return null;
}
// Hide status bar during approval wait - user is reviewing, not waiting
if (isAwaitingApproval) {
return null;
}
// Build the task toggle hint based on state
const todoHint = hasTodos
? todoExpanded
? 'ctrl+t to hide tasks'
: 'ctrl+t to show tasks'
: null;
// Show compacting state - yellow/orange color to indicate context management
if (isCompacting) {
const metaParts: string[] = [];
if (showTime) metaParts.push(`(${elapsedTime})`);
metaParts.push('Esc to cancel');
if (todoHint) metaParts.push(todoHint);
const metaContent = metaParts.join(' • ');
return (
<Box paddingX={1} marginTop={1} flexDirection="column">
{/* Line 1: spinner + compacting message */}
<Box flexDirection="row" alignItems="center">
<Text color="yellow">
<Spinner type="dots" />
</Text>
<Text color="yellow"> 📦 Compacting context...</Text>
</Box>
{/* Line 2: meta info */}
<Box marginLeft={2}>
<Text color="gray">{metaContent}</Text>
</Box>
</Box>
);
}
// Show initial processing state (before streaming starts) - green/teal color
// TODO: Rename this event/state to "reasoning" and associate it with actual reasoning tokens
// Currently "thinking" event fires before any response, not during reasoning token generation
if (isThinking) {
const metaParts: string[] = [];
if (showTime) metaParts.push(`(${elapsedTime})`);
if (tokenCount) metaParts.push(tokenCount);
metaParts.push('Esc to cancel');
if (todoHint) metaParts.push(todoHint);
const metaContent = metaParts.join(' • ');
return (
<Box paddingX={1} marginTop={1} flexDirection="column">
{/* Line 1: spinner + phrase */}
<Box flexDirection="row" alignItems="center">
<Text color="green">
<Spinner type="dots" />
</Text>
<Text color="green"> {phrase}</Text>
</Box>
{/* Line 2: meta info */}
<Box marginLeft={2}>
<Text color="gray">{metaContent}</Text>
</Box>
</Box>
);
}
// Show active streaming state - green/teal color
// Always use 2-line layout: phrase on first line, meta on second
// This prevents truncation and messy wrapping on any terminal width
const metaParts: string[] = [];
if (showTime) metaParts.push(`(${elapsedTime})`);
if (tokenCount) metaParts.push(tokenCount);
metaParts.push('Esc to cancel');
if (todoHint) metaParts.push(todoHint);
const metaContent = metaParts.join(' • ');
return (
<Box paddingX={1} marginTop={1} flexDirection="column">
{/* Line 1: spinner + phrase + queue count */}
<Box flexDirection="row" alignItems="center">
<Text color="green">
<Spinner type="dots" />
</Text>
<Text color="green"> {phrase}</Text>
{approvalQueueCount > 0 && (
<Text color="yellowBright"> {approvalQueueCount} queued</Text>
)}
</Box>
{/* Line 2: meta info (time, tokens, cancel hint) */}
<Box marginLeft={2}>
<Text color="gray">{metaContent}</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,719 @@
/**
* TextBufferInput Component
*
* Buffer is passed as prop from parent.
* Uses direct useKeypress for input handling (no ref chain).
* Parent owns the buffer and can read values directly.
*/
import React, { useCallback, useRef, useEffect } from 'react';
import { Box, Text, useStdout } from 'ink';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import type { TextBuffer } from './shared/text-buffer.js';
import type { PendingImage, PastedBlock } from '../state/types.js';
import { readClipboardImage } from '../utils/clipboardUtils.js';
/** Overlay trigger types for event-driven overlay detection */
export type OverlayTrigger = 'slash-autocomplete' | 'resource-autocomplete' | 'close';
/** Threshold for collapsing pasted content */
const PASTE_COLLAPSE_LINE_THRESHOLD = 3;
const PASTE_COLLAPSE_CHAR_THRESHOLD = 150;
/** Platform-aware keyboard shortcut labels */
const isMac = process.platform === 'darwin';
const KEY_LABELS = {
ctrlT: isMac ? '⌃T' : 'Ctrl+T',
altUp: isMac ? '⌥↑' : 'Alt+Up',
altDown: isMac ? '⌥↓' : 'Alt+Down',
};
interface TextBufferInputProps {
/** Text buffer (owned by parent) */
buffer: TextBuffer;
/** Called when user presses Enter to submit */
onSubmit: (value: string) => void;
/** Placeholder text when empty */
placeholder?: string | undefined;
/** Whether input handling is disabled (e.g., during processing) */
isDisabled?: boolean | undefined;
/** Called for history navigation (up/down at boundaries) */
onHistoryNavigate?: ((direction: 'up' | 'down') => void) | undefined;
/** Called to trigger overlay (slash command, @mention) */
onTriggerOverlay?: ((trigger: OverlayTrigger) => void) | undefined;
/** Maximum lines to show in viewport */
maxViewportLines?: number | undefined;
/** Whether this input should handle keypresses */
isActive: boolean;
/** Optional handler for keyboard scroll (PageUp/PageDown, Shift+arrows) */
onKeyboardScroll?: ((direction: 'up' | 'down') => void) | undefined;
/** Current number of attached images (for placeholder numbering) */
imageCount?: number | undefined;
/** Called when image is pasted from clipboard */
onImagePaste?: ((image: PendingImage) => void) | undefined;
/** Current pending images (for placeholder removal detection) */
images?: PendingImage[] | undefined;
/** Called when an image placeholder is removed from text */
onImageRemove?: ((imageId: string) => void) | undefined;
/** Current pasted blocks for collapse/expand feature */
pastedBlocks?: PastedBlock[] | undefined;
/** Called when a large paste is detected and should be collapsed */
onPasteBlock?: ((block: PastedBlock) => void) | undefined;
/** Called to update a pasted block (e.g., toggle collapse) */
onPasteBlockUpdate?: ((blockId: string, updates: Partial<PastedBlock>) => void) | undefined;
/** Called when a paste block placeholder is removed from text */
onPasteBlockRemove?: ((blockId: string) => void) | undefined;
/** Query to highlight in input text (for history search) */
highlightQuery?: string | undefined;
}
function isBackspaceKey(key: Key): boolean {
return key.name === 'backspace' || key.sequence === '\x7f' || key.sequence === '\x08';
}
function isForwardDeleteKey(key: Key): boolean {
return key.name === 'delete';
}
/** Renders text with optional query highlighting in green */
function HighlightedText({ text, query }: { text: string; query: string | undefined }) {
if (!query || !text) {
return <Text>{text}</Text>;
}
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const matchIndex = lowerText.indexOf(lowerQuery);
if (matchIndex === -1) {
return <Text>{text}</Text>;
}
const before = text.slice(0, matchIndex);
const match = text.slice(matchIndex, matchIndex + query.length);
const after = text.slice(matchIndex + query.length);
return (
<Text>
{before}
<Text color="green" bold>
{match}
</Text>
{after}
</Text>
);
}
export function TextBufferInput({
buffer,
onSubmit,
placeholder,
isDisabled = false,
onHistoryNavigate,
onTriggerOverlay,
maxViewportLines = 10,
isActive,
onKeyboardScroll,
imageCount = 0,
onImagePaste,
images = [],
onImageRemove,
pastedBlocks = [],
onPasteBlock,
onPasteBlockUpdate,
onPasteBlockRemove,
highlightQuery,
}: TextBufferInputProps) {
const { stdout } = useStdout();
const terminalWidth = stdout?.columns || 80;
// Use ref to track imageCount to avoid stale closure in async paste handler
const imageCountRef = useRef(imageCount);
useEffect(() => {
imageCountRef.current = imageCount;
}, [imageCount]);
// Use ref to track paste number for generating sequential IDs
const pasteCounterRef = useRef(pastedBlocks.length);
useEffect(() => {
// Update counter to be at least the current number of blocks
pasteCounterRef.current = Math.max(pasteCounterRef.current, pastedBlocks.length);
}, [pastedBlocks.length]);
// Check for removed image placeholders after text changes
const checkRemovedImages = useCallback(() => {
if (!onImageRemove || images.length === 0) return;
const currentText = buffer.text;
for (const img of images) {
if (!currentText.includes(img.placeholder)) {
onImageRemove(img.id);
}
}
}, [buffer, images, onImageRemove]);
// Check for removed paste block placeholders after text changes
const checkRemovedPasteBlocks = useCallback(() => {
if (!onPasteBlockRemove || pastedBlocks.length === 0) return;
const currentText = buffer.text;
for (const block of pastedBlocks) {
// Check if either the placeholder or the full text (when expanded) is present
const textToFind = block.isCollapsed ? block.placeholder : block.fullText;
if (!currentText.includes(textToFind)) {
onPasteBlockRemove(block.id);
}
}
}, [buffer, pastedBlocks, onPasteBlockRemove]);
// Find the currently expanded paste block (only one can be expanded at a time)
const findExpandedBlock = useCallback((): PastedBlock | null => {
return pastedBlocks.find((block) => !block.isCollapsed) || null;
}, [pastedBlocks]);
// Find which collapsed paste block the cursor is on (by placeholder)
const findCollapsedBlockAtCursor = useCallback((): PastedBlock | null => {
if (pastedBlocks.length === 0) return null;
const currentText = buffer.text;
const [cursorRow, cursorCol] = buffer.cursor;
const cursorOffset = getCursorPosition(buffer.lines, cursorRow, cursorCol);
for (const block of pastedBlocks) {
if (!block.isCollapsed) continue; // Skip expanded blocks
const startIdx = currentText.indexOf(block.placeholder);
if (startIdx === -1) continue;
const endIdx = startIdx + block.placeholder.length;
if (cursorOffset >= startIdx && cursorOffset <= endIdx) {
return block;
}
}
return null;
}, [buffer, pastedBlocks]);
// Handle Ctrl+T toggle:
// - If something is expanded: collapse it
// - If cursor is on a collapsed paste: expand it
const handlePasteToggle = useCallback(() => {
if (!onPasteBlockUpdate) return;
const expandedBlock = findExpandedBlock();
const currentText = buffer.text;
// If something is expanded, collapse it
if (expandedBlock) {
// Normalize for comparison (buffer might have different line endings)
const normalizedCurrent = currentText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const normalizedFullText = expandedBlock.fullText
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
const startIdx = normalizedCurrent.indexOf(normalizedFullText);
if (startIdx === -1) {
// Fallback: just mark as collapsed without text replacement
// This handles edge cases where text was modified
onPasteBlockUpdate(expandedBlock.id, { isCollapsed: true });
return;
}
// Replace full text with placeholder
const before = currentText.slice(0, startIdx);
const after = currentText.slice(startIdx + normalizedFullText.length);
const newText = before + expandedBlock.placeholder + after;
// Adjust cursor
const [cursorRow, cursorCol] = buffer.cursor;
const cursorOffset = getCursorPosition(buffer.lines, cursorRow, cursorCol);
let newCursorOffset = cursorOffset;
if (cursorOffset > startIdx) {
// Cursor is after the start of expanded block - adjust
const lengthDiff = expandedBlock.placeholder.length - normalizedFullText.length;
newCursorOffset = Math.max(startIdx, cursorOffset + lengthDiff);
}
buffer.setText(newText);
buffer.moveToOffset(Math.min(newCursorOffset, newText.length));
onPasteBlockUpdate(expandedBlock.id, { isCollapsed: true });
return;
}
// Otherwise, check if cursor is on a collapsed paste to expand
const collapsedBlock = findCollapsedBlockAtCursor();
if (collapsedBlock) {
const startIdx = currentText.indexOf(collapsedBlock.placeholder);
if (startIdx === -1) return;
// Replace placeholder with full text
const before = currentText.slice(0, startIdx);
const after = currentText.slice(startIdx + collapsedBlock.placeholder.length);
const newText = before + collapsedBlock.fullText + after;
buffer.setText(newText);
// Move cursor to start of expanded content
buffer.moveToOffset(startIdx);
onPasteBlockUpdate(collapsedBlock.id, { isCollapsed: false });
}
}, [buffer, findExpandedBlock, findCollapsedBlockAtCursor, onPasteBlockUpdate]);
// Handle keyboard input directly - reads buffer state fresh each time
const handleKeypress = useCallback(
(key: Key) => {
if (isDisabled) return;
// Read buffer state directly - always fresh, no stale closures
const currentText = buffer.text;
const cursorVisualRow = buffer.visualCursor[0];
const visualLines = buffer.allVisualLines;
// === KEYBOARD SCROLL (PageUp/PageDown, Shift+arrows) ===
if (onKeyboardScroll) {
if (key.name === 'pageup' || (key.shift && key.name === 'up')) {
onKeyboardScroll('up');
return;
}
if (key.name === 'pagedown' || (key.shift && key.name === 'down')) {
onKeyboardScroll('down');
return;
}
}
// === IMAGE PASTE (Ctrl+V) ===
// Check clipboard for image before letting normal paste through
if (key.ctrl && key.name === 'v' && onImagePaste) {
// Async clipboard check - fire and forget, don't block input
void (async () => {
try {
const clipboardImage = await readClipboardImage();
if (clipboardImage) {
// Use ref to get current count (avoids stale closure issue)
const currentCount = imageCountRef.current;
const imageNumber = currentCount + 1;
// Immediately increment ref to handle rapid pastes
imageCountRef.current = imageNumber;
const placeholder = `[Image ${imageNumber}]`;
const pendingImage: PendingImage = {
id: `img-${Date.now()}-${imageNumber}`,
data: clipboardImage.data,
mimeType: clipboardImage.mimeType,
placeholder,
};
onImagePaste(pendingImage);
buffer.insert(placeholder);
}
} catch {
// Clipboard read failed, ignore
}
})();
return;
}
// === NEWLINE DETECTION ===
const isCtrlJ = key.sequence === '\n';
const isShiftEnter =
key.sequence === '\\\r' ||
(key.name === 'return' && key.shift) ||
key.sequence === '\x1b[13;2u' ||
key.sequence === '\x1bOM';
const isPasteReturn = key.name === 'return' && key.paste;
const wantsNewline =
isCtrlJ || isShiftEnter || (key.name === 'return' && key.meta) || isPasteReturn;
if (wantsNewline) {
buffer.newline();
return;
}
// === SUBMIT (Enter) ===
if (key.name === 'return' && !key.paste) {
if (currentText.trim()) {
onSubmit(currentText);
}
return;
}
// === UNDO/REDO ===
if (key.ctrl && key.name === 'z' && !key.shift) {
buffer.undo();
return;
}
if ((key.ctrl && key.name === 'y') || (key.ctrl && key.shift && key.name === 'z')) {
buffer.redo();
return;
}
// === PASTE BLOCK TOGGLE (Ctrl+T) ===
if (key.ctrl && key.name === 't') {
handlePasteToggle();
return;
}
// === BACKSPACE ===
if (isBackspaceKey(key) && !key.meta) {
const prevText = buffer.text;
const [cursorRow, cursorCol] = buffer.cursor;
const cursorPos = getCursorPosition(buffer.lines, cursorRow, cursorCol);
buffer.backspace();
checkRemovedImages();
checkRemovedPasteBlocks();
// Check if we should close overlay after backspace
// NOTE: buffer.text is memoized and won't update until next render,
// so we calculate the expected new text ourselves
if (onTriggerOverlay && cursorPos > 0) {
const deletedChar = prevText[cursorPos - 1];
// Calculate what the text will be after backspace
const expectedNewText =
prevText.slice(0, cursorPos - 1) + prevText.slice(cursorPos);
if (deletedChar === '/' && cursorPos === 1) {
onTriggerOverlay('close');
} else if (deletedChar === '@') {
// Close if no valid @ mention remains
// A valid @ is at start of text or after whitespace
const hasValidAt = /(^|[\s])@/.test(expectedNewText);
if (!hasValidAt) {
onTriggerOverlay('close');
}
}
}
return;
}
// === FORWARD DELETE ===
if (isForwardDeleteKey(key)) {
buffer.del();
checkRemovedImages();
checkRemovedPasteBlocks();
return;
}
// === WORD DELETE ===
if (key.ctrl && key.name === 'w') {
buffer.deleteWordLeft();
checkRemovedImages();
checkRemovedPasteBlocks();
return;
}
if (key.meta && isBackspaceKey(key)) {
buffer.deleteWordLeft();
checkRemovedImages();
checkRemovedPasteBlocks();
return;
}
// === ARROW NAVIGATION ===
if (key.name === 'left') {
buffer.move(key.meta || key.ctrl ? 'wordLeft' : 'left');
return;
}
if (key.name === 'right') {
buffer.move(key.meta || key.ctrl ? 'wordRight' : 'right');
return;
}
// Cmd+Up: Move to start of input
if (key.meta && key.name === 'up') {
buffer.moveToOffset(0);
return;
}
// Cmd+Down: Move to end of input
if (key.meta && key.name === 'down') {
buffer.moveToOffset(currentText.length);
return;
}
if (key.name === 'up') {
// Only trigger history navigation when at top visual line
if (cursorVisualRow === 0 && onHistoryNavigate) {
onHistoryNavigate('up');
} else {
buffer.move('up');
}
return;
}
if (key.name === 'down') {
// Only trigger history navigation when at bottom visual line
if (cursorVisualRow >= visualLines.length - 1 && onHistoryNavigate) {
onHistoryNavigate('down');
} else {
buffer.move('down');
}
return;
}
// === LINE NAVIGATION ===
if (key.ctrl && key.name === 'a') {
buffer.move('home');
return;
}
if (key.ctrl && key.name === 'e') {
buffer.move('end');
return;
}
if (key.ctrl && key.name === 'k') {
buffer.killLineRight();
checkRemovedImages();
checkRemovedPasteBlocks();
return;
}
if (key.ctrl && key.name === 'u') {
buffer.killLineLeft();
checkRemovedImages();
checkRemovedPasteBlocks();
return;
}
// === WORD NAVIGATION ===
if (key.meta && key.name === 'b') {
buffer.move('wordLeft');
return;
}
if (key.meta && key.name === 'f') {
buffer.move('wordRight');
return;
}
// === CHARACTER INPUT ===
if (key.insertable && !key.ctrl && !key.meta) {
const [cursorRow, cursorCol] = buffer.cursor;
const cursorPos = getCursorPosition(buffer.lines, cursorRow, cursorCol);
// Check if this is a large paste that should be collapsed
if (key.paste && onPasteBlock) {
// Normalize line endings to \n for consistent handling
const pastedText = key.sequence.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lineCount = (pastedText.match(/\n/g)?.length ?? 0) + 1;
if (
lineCount >= PASTE_COLLAPSE_LINE_THRESHOLD ||
pastedText.length > PASTE_COLLAPSE_CHAR_THRESHOLD
) {
// Create collapsed paste block
pasteCounterRef.current += 1;
const pasteNumber = pasteCounterRef.current;
const placeholder = `[Paste ${pasteNumber}: ~${lineCount} lines]`;
const pasteBlock: PastedBlock = {
id: `paste-${Date.now()}-${pasteNumber}`,
number: pasteNumber,
fullText: pastedText,
lineCount,
isCollapsed: true,
placeholder,
};
// Insert placeholder instead of full text
buffer.insert(placeholder);
onPasteBlock(pasteBlock);
return;
}
}
buffer.insert(key.sequence, { paste: key.paste });
if (onTriggerOverlay) {
if (key.sequence === '/' && cursorPos === 0) {
onTriggerOverlay('slash-autocomplete');
} else if (key.sequence === '@') {
onTriggerOverlay('resource-autocomplete');
} else if (/\s/.test(key.sequence)) {
// Close resource autocomplete when user types whitespace
// Whitespace means user is done with the mention (either selected or abandoned)
onTriggerOverlay('close');
}
}
}
},
[
buffer,
isDisabled,
onSubmit,
onHistoryNavigate,
onTriggerOverlay,
onKeyboardScroll,
// imageCount intentionally omitted - callback uses imageCountRef which is synced via useEffect
onImagePaste,
checkRemovedImages,
checkRemovedPasteBlocks,
handlePasteToggle,
onPasteBlock,
]
);
// Subscribe to keypress events when active
useKeypress(handleKeypress, { isActive: isActive && !isDisabled });
// === RENDERING ===
// Read buffer state for rendering
const bufferText = buffer.text;
const visualCursor = buffer.visualCursor;
const visualLines = buffer.allVisualLines;
const cursorVisualRow = visualCursor[0];
const cursorVisualCol = visualCursor[1];
const separator = '─'.repeat(terminalWidth);
const totalLines = visualLines.length;
// Detect shell command mode (input starts with "!")
const isShellMode = bufferText.startsWith('!');
const promptPrefix = isShellMode ? '$ ' : '> ';
const promptColor = isShellMode ? 'yellow' : 'green';
const separatorColor = isShellMode ? 'yellow' : 'gray';
// Calculate visible window
let startLine = 0;
let endLine = totalLines;
if (totalLines > maxViewportLines) {
const halfViewport = Math.floor(maxViewportLines / 2);
startLine = Math.max(0, cursorVisualRow - halfViewport);
endLine = Math.min(totalLines, startLine + maxViewportLines);
if (endLine === totalLines) {
startLine = Math.max(0, totalLines - maxViewportLines);
}
}
const visibleLines = visualLines.slice(startLine, endLine);
// Empty state
if (bufferText === '') {
return (
<Box flexDirection="column" width={terminalWidth}>
<Text color="gray">{separator}</Text>
<Box width={terminalWidth}>
<Text color="green" bold>
{'> '}
</Text>
<Text inverse> </Text>
{placeholder && <Text color="gray">{placeholder}</Text>}
<Text>
{' '.repeat(Math.max(0, terminalWidth - 3 - (placeholder?.length || 0)))}
</Text>
</Box>
<Text color="gray">{separator}</Text>
</Box>
);
}
return (
<Box flexDirection="column" width={terminalWidth}>
<Text color={separatorColor}>{separator}</Text>
{startLine > 0 && (
<Text color="gray">
{' '} {startLine} more line{startLine > 1 ? 's' : ''} above (
{KEY_LABELS.altUp} to jump)
</Text>
)}
{visibleLines.map((line: string, idx: number) => {
const absoluteRow = startLine + idx;
const isFirst = absoluteRow === 0;
const prefix = isFirst ? promptPrefix : ' ';
const isCursorLine = absoluteRow === cursorVisualRow;
if (!isCursorLine) {
return (
<Box key={absoluteRow} width={terminalWidth}>
<Text color={promptColor} bold={isFirst}>
{prefix}
</Text>
<HighlightedText text={line} query={highlightQuery} />
<Text>
{' '.repeat(
Math.max(0, terminalWidth - prefix.length - line.length)
)}
</Text>
</Box>
);
}
const before = line.slice(0, cursorVisualCol);
const atCursor = line.charAt(cursorVisualCol) || ' ';
const after = line.slice(cursorVisualCol + 1);
return (
<Box key={absoluteRow} width={terminalWidth}>
<Text color={promptColor} bold={isFirst}>
{prefix}
</Text>
<HighlightedText text={before} query={highlightQuery} />
<Text inverse>{atCursor}</Text>
<HighlightedText text={after} query={highlightQuery} />
<Text>
{' '.repeat(
Math.max(
0,
terminalWidth - prefix.length - before.length - 1 - after.length
)
)}
</Text>
</Box>
);
})}
{endLine < totalLines && (
<Text color="gray">
{' '} {totalLines - endLine} more line{totalLines - endLine > 1 ? 's' : ''}{' '}
below ({KEY_LABELS.altDown} to jump)
</Text>
)}
{/* Paste block hints */}
{pastedBlocks.length > 0 && (
<PasteBlockHint
pastedBlocks={pastedBlocks}
expandedBlock={findExpandedBlock()}
cursorOnCollapsed={findCollapsedBlockAtCursor()}
/>
)}
<Text color={separatorColor}>{separator}</Text>
</Box>
);
}
/** Hint component for paste blocks */
function PasteBlockHint({
pastedBlocks,
expandedBlock,
cursorOnCollapsed,
}: {
pastedBlocks: PastedBlock[];
expandedBlock: PastedBlock | null;
cursorOnCollapsed: PastedBlock | null;
}) {
const collapsedCount = pastedBlocks.filter((b) => b.isCollapsed).length;
// If something is expanded, always show collapse hint
if (expandedBlock) {
return (
<Text color="cyan">
{' '}
{KEY_LABELS.ctrlT} to collapse expanded paste
</Text>
);
}
// If cursor is on a collapsed paste, show expand hint
if (cursorOnCollapsed) {
return (
<Text color="cyan">
{' '}
{KEY_LABELS.ctrlT} to expand paste
</Text>
);
}
// Otherwise show count of collapsed pastes
if (collapsedCount > 0) {
return (
<Text color="gray">
{' '}
{collapsedCount} collapsed paste{collapsedCount > 1 ? 's' : ''} ({KEY_LABELS.ctrlT}{' '}
on placeholder to expand)
</Text>
);
}
return null;
}
function getCursorPosition(lines: string[], cursorRow: number, cursorCol: number): number {
let pos = 0;
for (let i = 0; i < cursorRow; i++) {
pos += (lines[i]?.length ?? 0) + 1;
}
return pos + cursorCol;
}

View File

@@ -0,0 +1,158 @@
/**
* TodoPanel Component
*
* Displays the current todo list for workflow tracking.
* Shows todos with their status indicators (pending, in progress, completed).
*
* Display modes:
* - Processing + Collapsed: Shows "Next:" with the next pending/in-progress task
* - Processing + Expanded: Shows simple checklist with ☐/☑ indicators below status bar
* - Idle + Expanded: Shows boxed format with header
* - Idle + Collapsed: Hidden
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { TodoItem, TodoStatus } from '../state/types.js';
interface TodoPanelProps {
todos: TodoItem[];
/** Whether to show the full list or just the next task */
isExpanded: boolean;
/** Whether the agent is currently processing (affects display style) */
isProcessing?: boolean;
}
/**
* Get status indicator for a todo item (used in boxed mode)
*/
function getStatusIndicator(status: TodoStatus): { icon: string; color: string } {
switch (status) {
case 'completed':
return { icon: '✓', color: 'green' };
case 'in_progress':
return { icon: '●', color: 'yellow' };
case 'pending':
default:
return { icon: '○', color: 'gray' };
}
}
/**
* TodoPanel - Shows current todos for workflow tracking
*/
export function TodoPanel({ todos, isExpanded, isProcessing = false }: TodoPanelProps) {
if (todos.length === 0) {
return null;
}
// Sort todos by position
const sortedTodos = [...todos].sort((a, b) => a.position - b.position);
// Find the next task to work on (in_progress first, then first pending)
const currentTask = sortedTodos.find((t) => t.status === 'in_progress');
const nextPendingTask = sortedTodos.find((t) => t.status === 'pending');
const nextTask = currentTask || nextPendingTask;
// When idle (not processing)
if (!isProcessing) {
// Collapsed + idle = hidden
if (!isExpanded) {
return null;
}
// Expanded + idle = boxed format
const completedCount = todos.filter((t) => t.status === 'completed').length;
const totalCount = todos.length;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="gray"
paddingX={1}
marginX={1}
marginBottom={1}
>
{/* Header */}
<Box>
<Text bold color="cyan">
📋 Tasks{' '}
</Text>
<Text color="gray">
({completedCount}/{totalCount})
</Text>
<Text color="gray" dimColor>
{' '}
· ctrl+t to hide tasks
</Text>
</Box>
{/* Todo items */}
<Box flexDirection="column">
{sortedTodos.map((todo) => {
const { icon, color } = getStatusIndicator(todo.status);
const isCompleted = todo.status === 'completed';
const isInProgress = todo.status === 'in_progress';
return (
<Box key={todo.id}>
<Text color={color}>{icon} </Text>
<Text
color={isCompleted ? 'gray' : isInProgress ? 'white' : 'gray'}
strikethrough={isCompleted}
dimColor={!isInProgress && !isCompleted}
>
{isInProgress ? todo.activeForm : todo.content}
</Text>
</Box>
);
})}
</Box>
</Box>
);
}
// When processing - use minimal style
// Collapsed: show current task being worked on
if (!isExpanded) {
if (!currentTask) {
return null; // No active task
}
return (
<Box paddingX={1} marginBottom={1}>
<Box marginLeft={2}>
<Text color="gray"> </Text>
<Text color="gray">{currentTask.activeForm}</Text>
</Box>
</Box>
);
}
// Expanded: show simple checklist
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{sortedTodos.map((todo, index) => {
const isFirst = index === 0;
const isCompleted = todo.status === 'completed';
const isInProgress = todo.status === 'in_progress';
const checkbox = isCompleted ? '☑' : '☐';
return (
<Box key={todo.id} marginLeft={2}>
{/* Tree connector for first item, space for others */}
<Text color="gray">{isFirst ? '⎿ ' : ' '}</Text>
<Text color={isCompleted ? 'green' : isInProgress ? 'yellow' : 'white'}>
{checkbox}{' '}
</Text>
<Text color={isCompleted ? 'gray' : 'white'} dimColor={isCompleted}>
{todo.content}
</Text>
</Box>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,191 @@
/**
* Base Autocomplete Component
* Reusable autocomplete with filtering, scoring, and keyboard navigation
* Used by SlashCommandAutocomplete and ResourceAutocomplete
*/
import React, { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
import { Box, Text, useInput } from 'ink';
export interface BaseAutocompleteProps<T> {
items: T[];
query: string;
isVisible: boolean;
isLoading?: boolean;
onSelect: (item: T) => void;
onLoadIntoInput?: (text: string) => void;
onClose: () => void;
filterFn: (item: T, query: string) => boolean;
scoreFn: (item: T, query: string) => number;
formatItem: (item: T, isSelected: boolean) => ReactNode;
formatLoadText?: (item: T) => string;
title: string;
maxVisibleItems?: number;
loadingMessage?: string;
emptyMessage?: string;
borderColor?: string;
}
/**
* Generic autocomplete component with filtering, scoring, and keyboard navigation
*/
export function BaseAutocomplete<T>({
items,
query,
isVisible,
isLoading = false,
onSelect,
onLoadIntoInput,
onClose,
filterFn,
scoreFn,
formatItem,
formatLoadText,
title,
maxVisibleItems = 10,
loadingMessage = 'Loading...',
emptyMessage = 'No matches found',
borderColor = 'cyan',
}: BaseAutocompleteProps<T>) {
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const selectedIndexRef = useRef(0);
// Keep ref in sync
useEffect(() => {
selectedIndexRef.current = selectedIndex;
}, [selectedIndex]);
// Filter and sort items
const filteredItems = useMemo(() => {
if (!query) return items;
return items
.filter((item) => filterFn(item, query))
.sort((a, b) => {
const scoreA = scoreFn(a, query);
const scoreB = scoreFn(b, query);
return scoreB - scoreA; // Higher score first
});
}, [items, query, filterFn, scoreFn]);
// Reset selection when items change
useEffect(() => {
setSelectedIndex(0);
setScrollOffset(0);
}, [filteredItems.length]);
// Auto-scroll to keep selected item visible
useEffect(() => {
if (selectedIndex < scrollOffset) {
setScrollOffset(selectedIndex);
} else if (selectedIndex >= scrollOffset + maxVisibleItems) {
setScrollOffset(Math.max(0, selectedIndex - maxVisibleItems + 1));
}
}, [selectedIndex, scrollOffset, maxVisibleItems]);
// Calculate visible items
const visibleItems = useMemo(() => {
return filteredItems.slice(scrollOffset, scrollOffset + maxVisibleItems);
}, [filteredItems, scrollOffset, maxVisibleItems]);
// Handle keyboard navigation
useInput(
(input, key) => {
if (!isVisible) return;
const itemsLength = filteredItems.length;
if (itemsLength === 0) return;
if (key.upArrow) {
setSelectedIndex((prev) => (prev - 1 + itemsLength) % itemsLength);
}
if (key.downArrow) {
setSelectedIndex((prev) => (prev + 1) % itemsLength);
}
if (key.escape) {
onClose();
}
// Tab: Load into input for editing
if (key.tab && onLoadIntoInput && formatLoadText && itemsLength > 0) {
const item = filteredItems[selectedIndexRef.current];
if (item) {
onLoadIntoInput(formatLoadText(item));
}
return;
}
// Enter: Select item
if (key.return && itemsLength > 0) {
const item = filteredItems[selectedIndexRef.current];
if (item) {
onSelect(item);
}
}
},
{ isActive: isVisible }
);
if (!isVisible) return null;
if (isLoading) {
return (
<Box borderStyle="single" borderColor="gray" paddingX={1} paddingY={1}>
<Text color="gray">{loadingMessage}</Text>
</Box>
);
}
if (filteredItems.length === 0) {
return (
<Box borderStyle="single" borderColor="gray" paddingX={1} paddingY={1}>
<Text color="gray">{emptyMessage}</Text>
</Box>
);
}
const hasMoreAbove = scrollOffset > 0;
const hasMoreBelow = scrollOffset + maxVisibleItems < filteredItems.length;
const totalItems = filteredItems.length;
return (
<Box
borderStyle="single"
borderColor={borderColor}
flexDirection="column"
height={Math.min(maxVisibleItems + 3, totalItems + 3)}
>
<Box paddingX={1} paddingY={0}>
<Text color="gray">
{title} ({selectedIndex + 1}/{totalItems}) - navigate
{onLoadIntoInput && ', Tab load'}
{', Enter select, Esc close'}
</Text>
</Box>
{hasMoreAbove && (
<Box paddingX={1} paddingY={0}>
<Text color="gray">... ({scrollOffset} more above)</Text>
</Box>
)}
{visibleItems.map((item, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
return (
<Box key={actualIndex} paddingX={1} paddingY={0}>
{formatItem(item, isSelected)}
</Box>
);
})}
{hasMoreBelow && (
<Box paddingX={1} paddingY={0}>
<Text color="gray">
... ({totalItems - scrollOffset - maxVisibleItems} more below)
</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,221 @@
/**
* Base Selector Component
* Reusable selector with keyboard navigation for lists
* Used by ModelSelector and SessionSelector
*/
import {
useState,
useEffect,
useRef,
useMemo,
useCallback,
forwardRef,
useImperativeHandle,
type ReactNode,
} from 'react';
import { Box, Text } from 'ink';
import type { Key } from '../../hooks/useInputOrchestrator.js';
export interface BaseSelectorProps<T> {
items: T[];
isVisible: boolean;
isLoading?: boolean;
selectedIndex: number;
onSelectIndex: (index: number) => void;
onSelect: (item: T) => void;
onClose: () => void;
formatItem: (item: T, isSelected: boolean) => ReactNode;
title: string;
maxVisibleItems?: number;
loadingMessage?: string;
emptyMessage?: string;
borderColor?: string;
onTab?: (item: T) => void; // Optional Tab key handler
supportsTab?: boolean; // Whether to show Tab in instructions
}
export interface BaseSelectorHandle {
handleInput: (input: string, key: Key) => boolean;
}
/**
* Generic selector component with keyboard navigation and scrolling
*/
function BaseSelectorInner<T>(
{
items,
isVisible,
isLoading = false,
selectedIndex,
onSelectIndex,
onSelect,
onClose,
formatItem,
title,
maxVisibleItems = 8,
loadingMessage = 'Loading...',
emptyMessage = 'No items found',
borderColor = 'cyan',
onTab,
supportsTab = false,
}: BaseSelectorProps<T>,
ref: React.Ref<BaseSelectorHandle>
) {
// Track scroll offset as state, but derive during render when needed
const [scrollOffsetState, setScrollOffset] = useState(0);
const selectedIndexRef = useRef(selectedIndex);
const prevItemsLengthRef = useRef(items.length);
// Keep ref in sync
selectedIndexRef.current = selectedIndex;
// Derive the correct scroll offset during render (no second render needed)
// This handles both selectedIndex changes from parent AND items length changes
const scrollOffset = useMemo(() => {
const itemsChanged = items.length !== prevItemsLengthRef.current;
// Reset scroll if items changed significantly
if (itemsChanged && items.length <= maxVisibleItems) {
return 0;
}
let offset = scrollOffsetState;
// Adjust offset to keep selectedIndex visible
if (selectedIndex < offset) {
offset = selectedIndex;
} else if (selectedIndex >= offset + maxVisibleItems) {
offset = Math.max(0, selectedIndex - maxVisibleItems + 1);
}
// Clamp to valid range
const maxOffset = Math.max(0, items.length - maxVisibleItems);
return Math.min(maxOffset, Math.max(0, offset));
}, [selectedIndex, items.length, maxVisibleItems, scrollOffsetState]);
// Update refs after render (not during useMemo which can run multiple times)
useEffect(() => {
prevItemsLengthRef.current = items.length;
}, [items.length]);
// Sync scroll offset state after render if it changed
// This ensures the stored state is correct for next navigation
useEffect(() => {
if (scrollOffset !== scrollOffsetState) {
setScrollOffset(scrollOffset);
}
}, [scrollOffset, scrollOffsetState]);
// Handle selection change - only updates parent state
const handleSelectIndex = useCallback(
(newIndex: number) => {
selectedIndexRef.current = newIndex;
onSelectIndex(newIndex);
},
[onSelectIndex]
);
// Calculate visible items
const visibleItems = useMemo(() => {
return items.slice(scrollOffset, scrollOffset + maxVisibleItems);
}, [items, scrollOffset, maxVisibleItems]);
// Expose handleInput method via ref
useImperativeHandle(
ref,
() => ({
handleInput: (_input: string, key: Key): boolean => {
if (!isVisible) return false;
// Escape always works, regardless of item count
if (key.escape) {
onClose();
return true;
}
const itemsLength = items.length;
if (itemsLength === 0) return false;
if (key.upArrow) {
const nextIndex = (selectedIndexRef.current - 1 + itemsLength) % itemsLength;
handleSelectIndex(nextIndex);
return true;
}
if (key.downArrow) {
const nextIndex = (selectedIndexRef.current + 1) % itemsLength;
handleSelectIndex(nextIndex);
return true;
}
if (key.tab && onTab) {
const item = items[selectedIndexRef.current];
if (item !== undefined) {
onTab(item);
return true;
}
}
if (key.return && itemsLength > 0) {
const item = items[selectedIndexRef.current];
if (item !== undefined) {
onSelect(item);
return true;
}
}
return false;
},
}),
[isVisible, items, handleSelectIndex, onClose, onSelect, onTab]
);
if (!isVisible) return null;
if (isLoading) {
return (
<Box paddingX={0} paddingY={0}>
<Text color="gray">{loadingMessage}</Text>
</Box>
);
}
if (items.length === 0) {
return (
<Box paddingX={0} paddingY={0}>
<Text color="gray">{emptyMessage}</Text>
</Box>
);
}
// Build instruction text based on features
const instructions = supportsTab
? '↑↓ navigate, Tab load, Enter select, Esc close'
: '↑↓ navigate, Enter select, Esc close';
return (
<Box flexDirection="column">
<Box paddingX={0} paddingY={0}>
<Text color={borderColor} bold>
{title} ({selectedIndex + 1}/{items.length}) - {instructions}
</Text>
</Box>
{visibleItems.map((item, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
return (
<Box key={actualIndex} paddingX={0} paddingY={0}>
{formatItem(item, isSelected)}
</Box>
);
})}
</Box>
);
}
// Export with proper generic type support
export const BaseSelector = forwardRef(BaseSelectorInner) as <T>(
props: BaseSelectorProps<T> & { ref?: React.Ref<BaseSelectorHandle> }
) => ReturnType<typeof BaseSelectorInner>;

View File

@@ -0,0 +1,6 @@
/**
* Base components module exports
*/
export { BaseSelector, type BaseSelectorProps } from './BaseSelector.js';
export { BaseAutocomplete, type BaseAutocompleteProps } from './BaseAutocomplete.js';

View File

@@ -0,0 +1,42 @@
/**
* ChatView Component
* Main chat display area combining header and messages
*/
import React from 'react';
import { Box } from 'ink';
import { Header } from './Header.js';
import { MessageList } from './MessageList.js';
import type { Message, StartupInfo } from '../../state/types.js';
interface ChatViewProps {
messages: Message[];
modelName: string;
sessionId?: string | undefined;
hasActiveSession: boolean;
startupInfo: StartupInfo;
}
/**
* Pure presentational component for chat area
* Combines header and message list
*/
export function ChatView({
messages,
modelName,
sessionId,
hasActiveSession,
startupInfo,
}: ChatViewProps) {
return (
<Box flexDirection="column" flexGrow={1}>
<Header
modelName={modelName}
sessionId={sessionId}
hasActiveSession={hasActiveSession}
startupInfo={startupInfo}
/>
<MessageList messages={messages} />
</Box>
);
}

View File

@@ -0,0 +1,20 @@
/**
* Footer Component
* Displays keyboard shortcuts and help information
*/
import React from 'react';
import { Box, Text } from 'ink';
/**
* Pure presentational component for CLI footer
*/
export function Footer() {
return (
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text color="gray">
Shift+Enter/Ctrl+J: newline Ctrl+W: del word Ctrl+U: del line Ctrl+C: exit
</Text>
</Box>
);
}

View File

@@ -0,0 +1,106 @@
/**
* Header Component
* Displays CLI branding and session information
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { StartupInfo } from '../../state/types.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
interface HeaderProps {
modelName: string;
sessionId?: string | undefined;
hasActiveSession: boolean;
startupInfo: StartupInfo;
}
/**
* Pure presentational component for CLI header
* Automatically adjusts width to terminal size
*/
export function Header({ modelName, sessionId, hasActiveSession, startupInfo }: HeaderProps) {
const { columns } = useTerminalSize();
return (
<Box
borderStyle="single"
borderColor="gray"
paddingX={1}
flexDirection="column"
flexShrink={0}
width={columns}
>
<Box marginTop={1}>
<Text color="greenBright">
{`██████╗ ███████╗██╗ ██╗████████╗ ██████╗
██╔══██╗██╔════╝╚██╗██╔╝╚══██╔══╝██╔═══██╗
██║ ██║█████╗ ╚███╔╝ ██║ ██║ ██║
██║ ██║██╔══╝ ██╔██╗ ██║ ██║ ██║
██████╔╝███████╗██╔╝ ██╗ ██║ ╚██████╔╝
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝`}
</Text>
</Box>
{/* Model and Session */}
<Box marginTop={1} flexDirection="row">
<Text color="gray">Model: </Text>
<Text color="white">{modelName}</Text>
{hasActiveSession && sessionId && (
<>
<Text color="gray"> Session: </Text>
<Text color="white">{sessionId.slice(0, 8)}</Text>
</>
)}
</Box>
{/* MCP Servers and Tools */}
<Box flexDirection="row">
<Text color="gray">Servers: </Text>
<Text color="white">{startupInfo.connectedServers.count}</Text>
<Text color="gray"> Tools: </Text>
<Text color="white">{startupInfo.toolCount}</Text>
</Box>
{/* Failed connections warning */}
{startupInfo.failedConnections.length > 0 && (
<Box flexDirection="row">
<Text color="yellowBright">
Failed: {startupInfo.failedConnections.join(', ')}
</Text>
</Box>
)}
{/* Log file (only shown in dev mode) */}
{startupInfo.logFile && process.env.DEXTO_DEV_MODE === 'true' && (
<Box flexDirection="row">
<Text color="gray">Logs: {startupInfo.logFile}</Text>
</Box>
)}
{/* Update available notification */}
{startupInfo.updateInfo && (
<Box marginTop={1} flexDirection="row">
<Text color="yellow">
Update available: {startupInfo.updateInfo.current} {' '}
{startupInfo.updateInfo.latest}
</Text>
<Text color="gray"> Run: </Text>
<Text color="cyan">{startupInfo.updateInfo.updateCommand}</Text>
</Box>
)}
{/* Agent sync notification */}
{startupInfo.needsAgentSync && (
<Box marginTop={startupInfo.updateInfo ? 0 : 1} flexDirection="row">
<Text color="yellow">🔄 Agent configs have updates available. Run: </Text>
<Text color="cyan">dexto sync-agents</Text>
</Box>
)}
<Box marginBottom={1}>
<Text> </Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,311 @@
/**
* MessageItem Component
* Displays a single message with visual hierarchy
* Uses colors and spacing instead of explicit labels
*/
import { memo } from 'react';
import { Box, Text } from 'ink';
import wrapAnsi from 'wrap-ansi';
import type {
Message,
ConfigStyledData,
StatsStyledData,
HelpStyledData,
SessionListStyledData,
SessionHistoryStyledData,
LogConfigStyledData,
RunSummaryStyledData,
ShortcutsStyledData,
SysPromptStyledData,
} from '../../state/types.js';
import {
ConfigBox,
StatsBox,
HelpBox,
SessionListBox,
SessionHistoryBox,
LogConfigBox,
ShortcutsBox,
SyspromptBox,
} from './styled-boxes/index.js';
import { ToolResultRenderer } from '../renderers/index.js';
import { MarkdownText } from '../shared/MarkdownText.js';
import { ToolIcon } from './ToolIcon.js';
/**
* Strip <plan-mode>...</plan-mode> tags from content.
* Plan mode instructions are injected for the LLM but should not be shown in the UI.
* Only trims when a tag was actually removed to preserve user-intended formatting.
*/
function stripPlanModeTags(content: string): string {
// Remove <plan-mode>...</plan-mode> including any trailing whitespace
const stripped = content.replace(/<plan-mode>[\s\S]*?<\/plan-mode>\s*/g, '');
// Only trim if a tag was actually removed
return stripped === content ? content : stripped.trim();
}
/**
* Format milliseconds into a compact human-readable string
* Examples: "1.2s", "1m 23s", "1h 2m"
*/
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const tenths = Math.floor((ms % 1000) / 100);
if (seconds < 60) {
return `${seconds}.${tenths}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) {
return `${minutes}m ${remainingSeconds}s`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
interface MessageItemProps {
message: Message;
/** Terminal width for proper text wrapping calculations */
terminalWidth?: number;
}
/**
* Pure presentational component for a single message
* Visual hierarchy through colors and spacing only (no borders for easy text copying)
*
* Memoization with custom comparator prevents re-renders when message array changes
* but individual message content hasn't changed.
*/
export const MessageItem = memo(
({ message, terminalWidth = 80 }: MessageItemProps) => {
// Check for styled message first
if (message.styledType && message.styledData) {
switch (message.styledType) {
case 'config':
return <ConfigBox data={message.styledData as ConfigStyledData} />;
case 'stats':
return <StatsBox data={message.styledData as StatsStyledData} />;
case 'help':
return <HelpBox data={message.styledData as HelpStyledData} />;
case 'session-list':
return <SessionListBox data={message.styledData as SessionListStyledData} />;
case 'session-history':
return (
<SessionHistoryBox data={message.styledData as SessionHistoryStyledData} />
);
case 'log-config':
return <LogConfigBox data={message.styledData as LogConfigStyledData} />;
case 'run-summary': {
const data = message.styledData as RunSummaryStyledData;
const durationStr = formatDuration(data.durationMs);
// Only show tokens when >= 1000, using K notation
const tokensStr =
data.totalTokens >= 1000
? `, Used ${(data.totalTokens / 1000).toFixed(1)}K tokens`
: '';
return (
<Box marginTop={1} marginBottom={1} width={terminalWidth}>
<Text color="gray">
Worked for {durationStr}
{tokensStr}
</Text>
</Box>
);
}
case 'shortcuts':
return <ShortcutsBox data={message.styledData as ShortcutsStyledData} />;
case 'sysprompt':
return <SyspromptBox data={message.styledData as SysPromptStyledData} />;
}
}
// User message: '>' prefix with gray background
// Strip plan-mode tags before display (plan instructions are for LLM, not user)
// Properly wrap text accounting for prefix "> " (2 chars) and paddingX={1} (2 chars total)
if (message.role === 'user') {
const prefix = '> ';
const paddingChars = 2; // paddingX={1} = 1 char on each side
const availableWidth = Math.max(20, terminalWidth - prefix.length - paddingChars);
const displayContent = stripPlanModeTags(message.content);
const wrappedContent = wrapAnsi(displayContent, availableWidth, {
hard: true,
wordWrap: true,
trim: false,
});
const lines = wrappedContent.split('\n');
return (
<Box flexDirection="column" marginTop={2} marginBottom={1} width={terminalWidth}>
<Box flexDirection="column" paddingX={1} backgroundColor="gray">
{lines.map((line, i) => (
<Box key={i} flexDirection="row">
<Text color="green">{i === 0 ? prefix : ' '}</Text>
<Text color="white">{line}</Text>
</Box>
))}
</Box>
</Box>
);
}
// Assistant message: Gray circle indicator (unless continuation)
// IMPORTANT: width="100%" is required to prevent Ink layout failures on large content.
// Without width constraints, streaming content causes terminal blackout at ~50+ lines.
// marginTop={1} for consistent spacing with tool messages
if (message.role === 'assistant') {
// Continuation messages: no indicator, just content
if (message.isContinuation) {
return (
<Box flexDirection="column" width={terminalWidth}>
<MarkdownText>{message.content || ''}</MarkdownText>
</Box>
);
}
// Regular assistant message: bullet prefix inline with first line
// Text wraps at terminal width - wrapped lines may start at column 0
// This is simpler and avoids mid-word splitting issues with Ink's wrap
return (
<Box flexDirection="column" marginTop={1} width={terminalWidth}>
<MarkdownText bulletPrefix="⏺ ">{message.content || ''}</MarkdownText>
</Box>
);
}
// Tool message: Animated icon based on status
// - Running: green spinner + "Running..."
// - Finished (success): green dot
// - Finished (error): red dot
if (message.role === 'tool') {
// Use structured renderers if display data is available
const hasStructuredDisplay = message.toolDisplayData && message.toolContent;
const isRunning = message.toolStatus === 'running';
const isPending =
message.toolStatus === 'pending' || message.toolStatus === 'pending_approval';
// Check for sub-agent progress data
const subAgentProgress = message.subAgentProgress;
// Parse tool name and args for bold formatting: "ToolName(args)" → bold name + normal args
const parenIndex = message.content.indexOf('(');
const toolName =
parenIndex > 0 ? message.content.slice(0, parenIndex) : message.content;
const toolArgs = parenIndex > 0 ? message.content.slice(parenIndex) : '';
// Build the full tool header text for wrapping
// Don't include status suffix if we have sub-agent progress (it shows its own status)
const statusSuffix = subAgentProgress
? ''
: isRunning
? ' Running...'
: isPending
? ' Waiting...'
: '';
const fullToolText = `${toolName}${toolArgs}${statusSuffix}`;
// ToolIcon takes 2 chars ("● "), so available width is terminalWidth - 2
const iconWidth = 2;
const availableWidth = Math.max(20, terminalWidth - iconWidth);
const wrappedToolText = wrapAnsi(fullToolText, availableWidth, {
hard: true,
wordWrap: true,
trim: false,
});
const toolLines = wrappedToolText.split('\n');
return (
<Box flexDirection="column" marginTop={1} width={terminalWidth}>
{/* Tool header: icon + name + args + status text */}
{toolLines.map((line, i) => (
<Box key={i} flexDirection="row">
{i === 0 ? (
<ToolIcon
status={message.toolStatus || 'finished'}
isError={message.isError ?? false}
/>
) : (
<Text>{' '}</Text>
)}
<Text>
{i === 0 ? (
<>
<Text bold>{line.slice(0, toolName.length)}</Text>
<Text>{line.slice(toolName.length)}</Text>
</>
) : (
line
)}
</Text>
</Box>
))}
{/* Sub-agent progress line - show when we have progress data */}
{subAgentProgress && isRunning && (
<Box marginLeft={2}>
<Text color="gray">
{subAgentProgress.toolsCalled} tool
{subAgentProgress.toolsCalled !== 1 ? 's' : ''} called | Current:{' '}
{subAgentProgress.currentTool}
{subAgentProgress.tokenUsage &&
subAgentProgress.tokenUsage.total > 0
? ` | ${subAgentProgress.tokenUsage.total.toLocaleString()} tokens`
: ''}
</Text>
</Box>
)}
{/* Tool result - only show when finished */}
{hasStructuredDisplay ? (
<ToolResultRenderer
display={message.toolDisplayData!}
content={message.toolContent!}
/>
) : (
message.toolResult && (
<Box flexDirection="column">
<Text color="gray"> {message.toolResult}</Text>
</Box>
)
)}
</Box>
);
}
// System message: Compact gray text
return (
<Box flexDirection="column" marginBottom={1} width={terminalWidth}>
<Text color="gray">{message.content}</Text>
</Box>
);
},
// Custom comparator: only re-render if message content actually changed
(prev, next) => {
return (
prev.message.id === next.message.id &&
prev.message.content === next.message.content &&
prev.message.role === next.message.role &&
prev.message.toolStatus === next.message.toolStatus &&
prev.message.toolResult === next.message.toolResult &&
prev.message.isStreaming === next.message.isStreaming &&
prev.message.styledType === next.message.styledType &&
prev.message.styledData === next.message.styledData &&
prev.message.isContinuation === next.message.isContinuation &&
prev.message.isError === next.message.isError &&
prev.message.toolDisplayData === next.message.toolDisplayData &&
prev.message.toolContent === next.message.toolContent &&
prev.terminalWidth === next.terminalWidth &&
prev.message.subAgentProgress?.toolsCalled ===
next.message.subAgentProgress?.toolsCalled &&
prev.message.subAgentProgress?.currentTool ===
next.message.subAgentProgress?.currentTool &&
prev.message.subAgentProgress?.tokenUsage?.total ===
next.message.subAgentProgress?.tokenUsage?.total
);
}
);
MessageItem.displayName = 'MessageItem';

View File

@@ -0,0 +1,49 @@
/**
* MessageList Component
* Displays a list of messages with optional welcome message
*/
import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import { MessageItem } from './MessageItem.js';
import type { Message } from '../../state/types.js';
interface MessageListProps {
messages: Message[];
maxVisible?: number;
}
/**
* Pure presentational component for message list
* Shows only recent messages for performance
*/
export function MessageList({ messages, maxVisible = 50 }: MessageListProps) {
// Only render recent messages for performance
const visibleMessages = useMemo(() => {
return messages.slice(-maxVisible);
}, [messages, maxVisible]);
const hasMoreMessages = messages.length > maxVisible;
return (
<Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
{hasMoreMessages && (
<Box marginBottom={1}>
<Text color="gray">
... ({messages.length - maxVisible} earlier messages hidden)
</Text>
</Box>
)}
{visibleMessages.length === 0 && (
<Box marginY={2}>
<Text color="greenBright">
Welcome to Dexto CLI! Type your message below or use /help for commands.
</Text>
</Box>
)}
{visibleMessages.map((msg) => (
<MessageItem key={msg.id} message={msg} />
))}
</Box>
);
}

View File

@@ -0,0 +1,68 @@
/**
* QueuedMessagesDisplay - Shows queued messages waiting to be processed
*
* Similar to webui's QueuedMessagesDisplay.tsx but for Ink/terminal.
* Shows:
* - Count of queued messages
* - Keyboard hint (↑ to edit)
* - Truncated preview of each queued message
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { QueuedMessage, ContentPart } from '@dexto/core';
interface QueuedMessagesDisplayProps {
messages: QueuedMessage[];
}
/**
* Extract text content from ContentPart[]
*/
function getMessageText(content: ContentPart[]): string {
const textParts = content
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
.map((part) => part.text);
return textParts.join(' ') || '[attachment]';
}
/**
* Truncate text to fit terminal width
*/
function truncateText(text: string, maxLength: number = 60): string {
// Replace newlines with spaces for single-line display
const singleLine = text.replace(/\n/g, ' ').trim();
if (singleLine.length <= maxLength) {
return singleLine;
}
return singleLine.slice(0, maxLength - 3) + '...';
}
export function QueuedMessagesDisplay({ messages }: QueuedMessagesDisplayProps) {
if (messages.length === 0) return null;
return (
<Box flexDirection="column" marginBottom={1}>
{/* Header with count and keyboard hint */}
<Box>
<Text color="gray">
{messages.length} message{messages.length !== 1 ? 's' : ''} queued
</Text>
<Text color="gray"> </Text>
<Text color="gray"> to edit</Text>
</Box>
{/* Messages list */}
{messages.map((message, index) => (
<Box key={message.id} flexDirection="row">
{/* Arrow indicator - last message gets special indicator */}
<Text color="gray">{index === messages.length - 1 ? '↳ ' : '│ '}</Text>
{/* Message preview */}
<Text color="gray" italic>
{truncateText(getMessageText(message.content))}
</Text>
</Box>
))}
</Box>
);
}

View File

@@ -0,0 +1,77 @@
/**
* ToolIcon Component
* Animated icon for tool calls with status-based visual feedback
*/
import { useState, useEffect } from 'react';
import { Text } from 'ink';
import type { ToolStatus } from '../../state/types.js';
interface ToolIconProps {
status: ToolStatus;
isError?: boolean;
}
// Spinner frames for running animation
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
/**
* Animated tool icon that changes based on execution status
* - Running: Animated spinner (green/teal)
* - Finished (success): Green dot
* - Finished (error): Red dot
*/
export function ToolIcon({ status, isError }: ToolIconProps) {
const [frame, setFrame] = useState(0);
// Animate spinner only when actually running (not during approval)
useEffect(() => {
if (status !== 'running') {
return;
}
const interval = setInterval(() => {
setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
}, 80); // 80ms per frame for smooth animation
return () => clearInterval(interval);
}, [status]);
// Pending: static gray dot (tool call received, checking approval)
if (status === 'pending') {
return <Text color="gray"> </Text>;
}
// Pending approval: static yellowBright dot (waiting for user)
if (status === 'pending_approval') {
return (
<Text color="yellowBright" bold>
{' '}
</Text>
);
}
if (status === 'finished') {
// Error state: red dot
if (isError) {
return (
<Text color="red" bold>
{' '}
</Text>
);
}
// Success state: green dot
return (
<Text color="green" bold>
{' '}
</Text>
);
}
// Running state with spinner
return (
<Text color="green" bold>
{SPINNER_FRAMES[frame]}{' '}
</Text>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Chat components module exports
*/
export { Header } from './Header.js';
export { Footer } from './Footer.js';
export { MessageItem } from './MessageItem.js';
export { MessageList } from './MessageList.js';
export { ChatView } from './ChatView.js';
export { ToolIcon } from './ToolIcon.js';

View File

@@ -0,0 +1,81 @@
/**
* ConfigBox - Styled output for /config command
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { ConfigStyledData } from '../../../state/types.js';
import { StyledBox, StyledSection, StyledRow } from './StyledBox.js';
interface ConfigBoxProps {
data: ConfigStyledData;
}
export function ConfigBox({ data }: ConfigBoxProps) {
return (
<StyledBox title="Runtime Configuration" titleColor="cyan">
{/* Config file path at the top */}
{data.configFilePath && (
<Box>
<Text color="gray">Agent config: </Text>
<Text color="blue">{data.configFilePath}</Text>
</Box>
)}
<StyledSection title="LLM">
<StyledRow label="Provider" value={data.provider} />
<StyledRow label="Model" value={data.model} />
{data.maxTokens !== null && (
<StyledRow label="Max Tokens" value={data.maxTokens.toString()} />
)}
{data.temperature !== null && (
<StyledRow label="Temperature" value={data.temperature.toString()} />
)}
</StyledSection>
<StyledSection title="Tool Confirmation">
<StyledRow label="Mode" value={data.toolConfirmationMode} />
</StyledSection>
<StyledSection title="Sessions">
<StyledRow label="Max Sessions" value={data.maxSessions} />
<StyledRow label="Session TTL" value={data.sessionTTL} />
</StyledSection>
<StyledSection title="MCP Servers">
{data.mcpServers.length > 0 ? (
data.mcpServers.map((server) => (
<Box key={server}>
<Text color="cyan">{server}</Text>
</Box>
))
) : (
<Text color="gray">No MCP servers configured</Text>
)}
</StyledSection>
{data.promptsCount > 0 && (
<StyledSection title="Prompts">
<Text color="gray">{data.promptsCount} prompt(s) configured</Text>
</StyledSection>
)}
{data.pluginsEnabled.length > 0 && (
<StyledSection title="Plugins">
{data.pluginsEnabled.map((plugin) => (
<Box key={plugin}>
<Text color="green">{plugin}</Text>
</Box>
))}
</StyledSection>
)}
{/* Footer note about CLI-populated fields */}
<Box marginTop={1}>
<Text color="gray" italic>
Note: Some fields (logs, database paths) are auto-populated by the CLI.
</Text>
</Box>
</StyledBox>
);
}

View File

@@ -0,0 +1,47 @@
/**
* HelpBox - Styled output for /help command
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { HelpStyledData } from '../../../state/types.js';
import { StyledBox } from './StyledBox.js';
interface HelpBoxProps {
data: HelpStyledData;
}
export function HelpBox({ data }: HelpBoxProps) {
// Group commands by category
const categories = data.commands.reduce(
(acc, cmd) => {
const cat = cmd.category || 'Other';
if (!acc[cat]) {
acc[cat] = [];
}
acc[cat].push(cmd);
return acc;
},
{} as Record<string, typeof data.commands>
);
return (
<StyledBox title="Available Commands">
{Object.entries(categories).map(([category, commands]) => (
<Box key={category} flexDirection="column" marginTop={1}>
<Text bold color="gray">
{category}
</Text>
{commands.map((cmd) => (
<Box key={cmd.name} marginLeft={2}>
<Box width={16}>
<Text color="cyan">/{cmd.name}</Text>
</Box>
<Text color="gray">{cmd.description}</Text>
</Box>
))}
</Box>
))}
</StyledBox>
);
}

View File

@@ -0,0 +1,44 @@
/**
* LogConfigBox - Styled output for /log command (no args)
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { LogConfigStyledData } from '../../../state/types.js';
import { StyledBox, StyledRow, StyledListItem } from './StyledBox.js';
interface LogConfigBoxProps {
data: LogConfigStyledData;
}
export function LogConfigBox({ data }: LogConfigBoxProps) {
return (
<StyledBox title="Logging Configuration">
<Box marginTop={1} flexDirection="column">
<StyledRow label="Current level" value={data.currentLevel} valueColor="green" />
{data.logFile && process.env.DEXTO_DEV_MODE === 'true' && (
<StyledRow label="Log file" value={data.logFile} />
)}
</Box>
<Box marginTop={1} flexDirection="column">
<Text color="gray">Available levels (least to most verbose):</Text>
{data.availableLevels.map((level) => {
const isCurrent = level === data.currentLevel;
return (
<StyledListItem
key={level}
icon={isCurrent ? '>' : ' '}
text={level}
isActive={isCurrent}
/>
);
})}
</Box>
<Box marginTop={1}>
<Text color="gray">Use /log &lt;level&gt; to change level</Text>
</Box>
</StyledBox>
);
}

View File

@@ -0,0 +1,75 @@
/**
* SessionHistoryBox - Styled output for /session history command
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { SessionHistoryStyledData } from '../../../state/types.js';
import { StyledBox } from './StyledBox.js';
interface SessionHistoryBoxProps {
data: SessionHistoryStyledData;
}
/**
* Truncate content to a reasonable preview length
*/
function truncateContent(content: string, maxLength: number = 100): string {
if (content.length <= maxLength) return content;
return content.slice(0, maxLength) + '...';
}
/**
* Get role color and icon
*/
function getRoleStyle(role: string): { color: string; icon: string } {
switch (role) {
case 'user':
return { color: 'blue', icon: '>' };
case 'assistant':
return { color: 'green', icon: '|' };
case 'system':
return { color: 'orange', icon: '#' };
case 'tool':
return { color: 'green', icon: '*' };
default:
return { color: 'white', icon: '-' };
}
}
export function SessionHistoryBox({ data }: SessionHistoryBoxProps) {
if (data.messages.length === 0) {
return (
<StyledBox title={`Session History: ${data.sessionId.slice(0, 8)}`}>
<Box marginTop={1}>
<Text color="gray">No messages in this session yet.</Text>
</Box>
</StyledBox>
);
}
return (
<StyledBox title={`Session History: ${data.sessionId.slice(0, 8)}`}>
{data.messages.map((msg, index) => {
const style = getRoleStyle(msg.role);
return (
<Box key={index} flexDirection="column" marginTop={index === 0 ? 1 : 0}>
<Box>
<Text color={style.color} bold>
{style.icon}{' '}
</Text>
<Text color={style.color}>[{msg.role}]</Text>
<Text color="gray"> {msg.timestamp}</Text>
</Box>
<Box marginLeft={2}>
<Text>{truncateContent(msg.content)}</Text>
</Box>
</Box>
);
})}
<Box marginTop={1}>
<Text color="gray">Total: {data.total} messages</Text>
</Box>
</StyledBox>
);
}

View File

@@ -0,0 +1,51 @@
/**
* SessionListBox - Styled output for /session list command
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { SessionListStyledData } from '../../../state/types.js';
import { StyledBox } from './StyledBox.js';
interface SessionListBoxProps {
data: SessionListStyledData;
}
export function SessionListBox({ data }: SessionListBoxProps) {
if (data.sessions.length === 0) {
return (
<StyledBox title="Sessions">
<Box marginTop={1}>
<Text color="gray">No sessions found.</Text>
</Box>
<Box marginTop={1}>
<Text color="gray">Run `dexto` to start a new session.</Text>
</Box>
</StyledBox>
);
}
return (
<StyledBox title="Sessions">
{data.sessions.map((session) => (
<Box key={session.id} marginTop={1}>
<Box width={12}>
<Text color={session.isCurrent ? 'green' : 'cyan'} bold={session.isCurrent}>
{session.isCurrent ? '>' : ' '} {session.id.slice(0, 8)}
</Text>
</Box>
<Box width={14}>
<Text color="gray">{session.messageCount} messages</Text>
</Box>
<Text color="gray">{session.lastActive}</Text>
</Box>
))}
<Box marginTop={1}>
<Text color="gray">Total: {data.total} sessions</Text>
</Box>
<Box>
<Text color="gray">Use /resume to switch sessions</Text>
</Box>
</StyledBox>
);
}

Some files were not shown because too many files have changed in this diff Show More