diff --git a/README.md b/README.md index 7927b5f..bab60d4 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,28 @@ Full-screen terminal interface with real-time streaming responses. - `Shift+Up/Down` - Scroll log panel - `Ctrl+C` (twice) - Exit +### Vim Motions + +Optional vim-style keyboard navigation for power users. Enable in settings. + +**Normal Mode:** +- `j/k` - Scroll down/up +- `gg/G` - Jump to top/bottom +- `Ctrl+d/u` - Half page scroll +- `/` - Search, `n/N` - Next/prev match +- `i` - Enter insert mode +- `:` - Command mode (`:q` quit, `:w` save) + +**Configuration:** +```json +{ + "vim": { + "enabled": true, + "startInNormalMode": true + } +} +``` + ### Command Menu Press `/` to access all commands organized by category. @@ -250,6 +272,10 @@ CodeTyper has access to these built-in tools: | `read` | Read file contents | | `write` | Create or overwrite files | | `edit` | Find and replace in files | +| `glob` | Find files by pattern | +| `grep` | Search file contents | +| `lsp` | Language Server Protocol operations | +| `web_search` | Search the web | | `todo-read` | Read current todo list | | `todo-write` | Update todo list | @@ -263,6 +289,87 @@ Connect external MCP (Model Context Protocol) servers for extended capabilities: # Then add a new server ``` +## Extensibility + +### Hooks System + +Lifecycle hooks for intercepting tool execution and session events. + +**Hook Events:** +- `PreToolUse` - Validate/modify before tool execution +- `PostToolUse` - Side effects after tool execution +- `SessionStart` - At session initialization +- `SessionEnd` - At session termination +- `UserPromptSubmit` - When user submits input +- `Stop` - When execution stops + +**Configuration** (`.codetyper/hooks.json`): +```json +{ + "hooks": [ + { "event": "PreToolUse", "script": ".codetyper/hooks/validate.sh", "timeout": 5000 }, + { "event": "PostToolUse", "script": ".codetyper/hooks/notify.sh" } + ] +} +``` + +**Exit Codes:** +- `0` - Allow (optionally output `{"updatedInput": {...}}` to modify args) +- `1` - Warn but continue +- `2` - Block execution + +### Plugin System + +Extend CodeTyper with custom tools, commands, and hooks. + +**Plugin Structure:** +``` +.codetyper/plugins/{name}/ +├── plugin.json # Manifest +├── tools/ +│ └── *.ts # Custom tool definitions +├── commands/ +│ └── *.md # Slash commands +└── hooks/ + └── *.sh # Plugin-specific hooks +``` + +**Manifest** (`plugin.json`): +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "tools": [{ "name": "custom_tool", "file": "tool.ts" }], + "commands": [{ "name": "mycommand", "file": "cmd.md" }] +} +``` + +**Custom Tool Definition:** +```typescript +import { z } from "zod"; +export default { + name: "custom_tool", + description: "Does something", + parameters: z.object({ input: z.string() }), + execute: async (args, ctx) => ({ success: true, title: "Done", output: "..." }), +}; +``` + +### Session Forking + +Branch and rewind session history for experimentation. + +**Commands:** +| Command | Description | +|---------|-------------| +| `/snapshot [name]` | Create checkpoint | +| `/rewind [n\|name]` | Go back to snapshot | +| `/fork [name]` | Branch current session | +| `/forks` | List all forks | +| `/switch [name]` | Switch to fork | + +Sessions are stored in `.codetyper/sessions/` with automatic commit message suggestions. + ## Development ```bash diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4d81f19..650aa38 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Hooks System**: Lifecycle hooks for extensibility + - 6 hook events: PreToolUse, PostToolUse, SessionStart, SessionEnd, UserPromptSubmit, Stop + - Exit code control flow (0=allow, 1=warn, 2=block) + - JSON input/output via stdin/stdout + - Modified arguments via `updatedInput` + - Global + local configuration support + - Configurable timeout per hook + +- **Plugin System**: Custom tools, commands, and hooks + - Plugin manifest with version and capabilities + - Custom tool definitions via TypeScript + - Custom slash commands via Markdown with frontmatter + - Plugin-specific hooks + - Global (~/.config/codetyper/plugins/) + local (.codetyper/plugins/) + - Dynamic tool/command registration + +- **Session Forking/Rewind**: Branch and time-travel session history + - Named snapshots at any point in conversation + - Rewind to any snapshot by name or index + - Fork branches from any snapshot + - Switch between forks + - Suggested commit messages based on session content + - Commands: /snapshot, /rewind, /fork, /forks, /switch + +- **Vim Motions**: Vim-style keyboard navigation + - 4 modes: Normal, Insert, Command, Visual + - Scroll navigation (j/k, gg/G, Ctrl+d/u) + - Search with highlighting (/, n/N) + - Command mode (:q, :w, :wq, :nohl) + - Mode indicator in status line + - Configurable via settings.json + - **Home Screen**: New welcome screen with centered gradient logo - Displays version, provider, and model info - Transitions to session view on first message diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index a016fe1..9a26f4b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -135,11 +135,21 @@ docs: update README with new CLI options src/ ├── index.ts # Entry point only ├── commands/ # CLI command implementations +├── constants/ # Centralized constants +├── interfaces/ # Interface definitions ├── providers/ # LLM provider integrations +├── services/ # Business logic services +│ ├── hooks-service.ts # Lifecycle hooks +│ ├── plugin-service.ts # Plugin management +│ ├── plugin-loader.ts # Plugin discovery +│ └── session-fork-service.ts # Session forking +├── stores/ # Zustand state stores +│ └── vim-store.ts # Vim mode state ├── tools/ # Agent tools (bash, read, write, edit) ├── tui/ # Terminal UI components -│ └── components/ # Reusable UI components -└── types.ts # Shared type definitions +│ ├── components/ # Reusable UI components +│ └── hooks/ # React hooks (useVimMode, etc.) +└── types/ # Type definitions ``` ### Testing @@ -170,11 +180,16 @@ describe('PermissionManager', () => { | File | Purpose | |------|---------| | `src/index.ts` | CLI entry point, command registration | -| `src/agent.ts` | Agent loop, tool orchestration | -| `src/permissions.ts` | Permission system | +| `src/services/agent.ts` | Agent loop, tool orchestration | +| `src/services/permissions.ts` | Permission system | +| `src/services/hooks-service.ts` | Lifecycle hooks | +| `src/services/plugin-service.ts` | Plugin management | +| `src/services/session-fork-service.ts` | Session forking | | `src/commands/chat-tui.tsx` | Main TUI command | | `src/tui/App.tsx` | Root TUI component | | `src/tui/store.ts` | Zustand state management | +| `src/stores/vim-store.ts` | Vim mode state | +| `src/tui/hooks/useVimMode.ts` | Vim keyboard handling | ### Adding a New Provider @@ -192,6 +207,28 @@ describe('PermissionManager', () => { 4. Register in `src/tools/index.ts` 5. Add permission handling if needed +### Adding a Hook Event + +1. Add event type to `src/types/hooks.ts` +2. Add constants to `src/constants/hooks.ts` +3. Add input type for the event +4. Implement execution in `src/services/hooks-service.ts` +5. Call hook from appropriate location + +### Creating a Plugin + +1. Create directory in `.codetyper/plugins/{name}/` +2. Add `plugin.json` manifest +3. Add tools in `tools/*.ts` +4. Add commands in `commands/*.md` +5. Add hooks in `hooks/*.sh` + +### Adding Vim Bindings + +1. Add binding to `VIM_DEFAULT_BINDINGS` in `src/constants/vim.ts` +2. Add action handler in `src/tui/hooks/useVimMode.ts` +3. Update documentation + ## Questions? - Open a GitHub issue for questions diff --git a/src/commands/components/execute/index.ts b/src/commands/components/execute/index.ts index 983dfef..dfdb838 100644 --- a/src/commands/components/execute/index.ts +++ b/src/commands/components/execute/index.ts @@ -23,6 +23,7 @@ import { } from "@utils/terminal"; import { createCallbacks } from "@commands/chat-tui"; import { agentLoader } from "@services/agent-loader"; +import { projectSetupService } from "@services/project-setup-service"; interface ExecuteContext { state: ChatServiceState | null; @@ -150,6 +151,9 @@ const execute = async (options: ChatTUIOptions): Promise => { baseSystemPrompt: null, }; + // Setup project on startup (add .codetyper to gitignore, create default agents) + await projectSetupService.setupProject(process.cwd()); + const { state, session } = await initializeChatService(options); ctx.state = state; // Store the original system prompt before any agent modifications diff --git a/src/constants/hooks.ts b/src/constants/hooks.ts new file mode 100644 index 0000000..9cc7eef --- /dev/null +++ b/src/constants/hooks.ts @@ -0,0 +1,80 @@ +/** + * Hook System Constants + * + * Constants for lifecycle hooks + */ + +import type { HookEventType } from "@/types/hooks"; + +/** + * Hook configuration file name + */ +export const HOOKS_CONFIG_FILE = "hooks.json"; + +/** + * Default hook timeout in milliseconds + */ +export const DEFAULT_HOOK_TIMEOUT = 30000; + +/** + * Hook exit codes and their meanings + */ +export const HOOK_EXIT_CODES = { + /** Allow execution to continue */ + ALLOW: 0, + /** Warn but continue execution */ + WARN: 1, + /** Block execution */ + BLOCK: 2, +} as const; + +/** + * Hook event type labels for display + */ +export const HOOK_EVENT_LABELS: Record = { + PreToolUse: "Pre-Tool Use", + PostToolUse: "Post-Tool Use", + SessionStart: "Session Start", + SessionEnd: "Session End", + UserPromptSubmit: "User Prompt Submit", + Stop: "Stop", +}; + +/** + * Hook event type descriptions + */ +export const HOOK_EVENT_DESCRIPTIONS: Record = { + PreToolUse: "Runs before a tool is executed. Can modify args or block execution.", + PostToolUse: "Runs after a tool completes. For notifications or logging.", + SessionStart: "Runs when a new session begins.", + SessionEnd: "Runs when a session ends.", + UserPromptSubmit: "Runs when user submits a prompt. Can modify or block.", + Stop: "Runs when execution is stopped (interrupt, complete, or error).", +}; + +/** + * All available hook event types + */ +export const HOOK_EVENT_TYPES: readonly HookEventType[] = [ + "PreToolUse", + "PostToolUse", + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "Stop", +] as const; + +/** + * Maximum output size from hook script in bytes + */ +export const MAX_HOOK_OUTPUT_SIZE = 1024 * 1024; // 1MB + +/** + * Hook script execution shell + */ +export const HOOK_SHELL = "/bin/bash"; + +/** + * Environment variables passed to hooks + */ +export const HOOK_ENV_PREFIX = "CODETYPER_HOOK_"; diff --git a/src/constants/plugin.ts b/src/constants/plugin.ts new file mode 100644 index 0000000..41f3499 --- /dev/null +++ b/src/constants/plugin.ts @@ -0,0 +1,109 @@ +/** + * Plugin System Constants + * + * Constants for the plugin architecture + */ + +import type { PluginCapability } from "@/types/plugin"; + +/** + * Plugin directory name + */ +export const PLUGINS_DIR = "plugins"; + +/** + * Plugin manifest file name + */ +export const PLUGIN_MANIFEST_FILE = "plugin.json"; + +/** + * Plugin subdirectories + */ +export const PLUGIN_SUBDIRS = { + tools: "tools", + commands: "commands", + hooks: "hooks", +} as const; + +/** + * Plugin tool name prefix separator + */ +export const PLUGIN_TOOL_SEPARATOR = ":"; + +/** + * Maximum plugin load timeout in milliseconds + */ +export const PLUGIN_LOAD_TIMEOUT = 5000; + +/** + * Maximum number of plugins to load + */ +export const MAX_PLUGINS = 50; + +/** + * Default plugin capabilities + */ +export const DEFAULT_PLUGIN_CAPABILITIES: PluginCapability[] = []; + +/** + * All available plugin capabilities + */ +export const ALL_PLUGIN_CAPABILITIES: PluginCapability[] = [ + "filesystem", + "network", + "shell", + "mcp", +]; + +/** + * Plugin capability labels + */ +export const PLUGIN_CAPABILITY_LABELS: Record = { + filesystem: "File System Access", + network: "Network Access", + shell: "Shell Execution", + mcp: "MCP Integration", +}; + +/** + * Plugin capability descriptions + */ +export const PLUGIN_CAPABILITY_DESCRIPTIONS: Record = { + filesystem: "Can read and write files on disk", + network: "Can make network requests", + shell: "Can execute shell commands", + mcp: "Can interact with MCP servers", +}; + +/** + * Command file extension + */ +export const COMMAND_FILE_EXTENSION = ".md"; + +/** + * Tool file extension + */ +export const TOOL_FILE_EXTENSION = ".ts"; + +/** + * Hook script extensions + */ +export const HOOK_SCRIPT_EXTENSIONS = [".sh", ".bash"]; + +/** + * Command frontmatter delimiter + */ +export const COMMAND_FRONTMATTER_DELIMITER = "---"; + +/** + * Plugin load errors + */ +export const PLUGIN_ERRORS = { + MANIFEST_NOT_FOUND: "Plugin manifest not found", + MANIFEST_INVALID: "Plugin manifest is invalid", + TOOL_LOAD_FAILED: "Failed to load tool", + COMMAND_LOAD_FAILED: "Failed to load command", + HOOK_LOAD_FAILED: "Failed to load hook", + DUPLICATE_TOOL: "Tool name already exists", + DUPLICATE_COMMAND: "Command name already exists", +} as const; diff --git a/src/constants/session-fork.ts b/src/constants/session-fork.ts new file mode 100644 index 0000000..c3f8d33 --- /dev/null +++ b/src/constants/session-fork.ts @@ -0,0 +1,104 @@ +/** + * Session Fork Constants + * + * Constants for session snapshots and forks + */ + +/** + * File extension for fork files + */ +export const FORK_FILE_EXTENSION = ".fork.json"; + +/** + * Main fork name + */ +export const MAIN_FORK_NAME = "main"; + +/** + * Default snapshot name prefix + */ +export const DEFAULT_SNAPSHOT_PREFIX = "snapshot"; + +/** + * Maximum snapshots per fork + */ +export const MAX_SNAPSHOTS_PER_FORK = 100; + +/** + * Maximum forks per session + */ +export const MAX_FORKS_PER_SESSION = 50; + +/** + * Fork file version for migration + */ +export const FORK_FILE_VERSION = 1; + +/** + * Session fork directory name + */ +export const FORKS_SUBDIR = "sessions"; + +/** + * Auto-snapshot triggers + */ +export const AUTO_SNAPSHOT_TRIGGERS = { + /** Messages since last snapshot to trigger auto-snapshot */ + MESSAGE_THRESHOLD: 10, + /** Time in ms since last snapshot to trigger auto-snapshot */ + TIME_THRESHOLD: 300000, // 5 minutes +} as const; + +/** + * Commit message templates + */ +export const COMMIT_MESSAGE_TEMPLATES = { + /** Template for code changes */ + CODE: "feat(session): {summary} [{count} messages]", + /** Template for fix changes */ + FIX: "fix(session): {summary} [{count} messages]", + /** Template for refactor changes */ + REFACTOR: "refactor(session): {summary} [{count} messages]", + /** Template for docs changes */ + DOCS: "docs(session): {summary} [{count} messages]", + /** Default template */ + DEFAULT: "chore(session): {summary} [{count} messages]", +} as const; + +/** + * Keywords for detecting commit types + */ +export const COMMIT_TYPE_KEYWORDS = { + CODE: ["implement", "add", "create", "build", "feature"], + FIX: ["fix", "bug", "resolve", "correct", "patch"], + REFACTOR: ["refactor", "restructure", "reorganize", "improve"], + DOCS: ["document", "readme", "comment", "explain"], +} as const; + +/** + * Fork command names + */ +export const FORK_COMMANDS = { + SNAPSHOT: "/snapshot", + REWIND: "/rewind", + FORK: "/fork", + FORKS: "/forks", + SWITCH: "/switch", +} as const; + +/** + * Error messages for fork operations + */ +export const FORK_ERRORS = { + SESSION_NOT_FOUND: "Session not found", + SNAPSHOT_NOT_FOUND: "Snapshot not found", + FORK_NOT_FOUND: "Fork not found", + MAX_SNAPSHOTS_REACHED: "Maximum snapshots per fork reached", + MAX_FORKS_REACHED: "Maximum forks per session reached", + INVALID_SNAPSHOT_NAME: "Invalid snapshot name", + INVALID_FORK_NAME: "Invalid fork name", + DUPLICATE_SNAPSHOT_NAME: "Snapshot name already exists", + DUPLICATE_FORK_NAME: "Fork name already exists", + CANNOT_REWIND_TO_FUTURE: "Cannot rewind to a future snapshot", + NO_SNAPSHOTS_TO_REWIND: "No snapshots to rewind to", +} as const; diff --git a/src/constants/vim.ts b/src/constants/vim.ts new file mode 100644 index 0000000..1ba7335 --- /dev/null +++ b/src/constants/vim.ts @@ -0,0 +1,155 @@ +/** + * Vim Mode Constants + * + * Constants for vim-style navigation and editing + */ + +import type { VimMode, VimKeyBinding, VimConfig } from "@/types/vim"; + +/** + * Mode labels for display + */ +export const VIM_MODE_LABELS: Record = { + normal: "NORMAL", + insert: "INSERT", + command: "COMMAND", + visual: "VISUAL", +}; + +/** + * Mode colors for display + */ +export const VIM_MODE_COLORS: Record = { + normal: "blue", + insert: "green", + command: "yellow", + visual: "magenta", +}; + +/** + * Mode hints for status line + */ +export const VIM_MODE_HINTS: Record = { + normal: "j/k scroll, i insert, : command", + insert: "Esc to normal", + command: "Enter to execute, Esc to cancel", + visual: "y yank, d delete, Esc cancel", +}; + +/** + * Default key bindings + */ +export const VIM_DEFAULT_BINDINGS: VimKeyBinding[] = [ + // Normal mode - Navigation + { key: "j", mode: "normal", action: "scroll_down", description: "Scroll down" }, + { key: "k", mode: "normal", action: "scroll_up", description: "Scroll up" }, + { key: "d", mode: "normal", action: "scroll_half_down", ctrl: true, description: "Half page down" }, + { key: "u", mode: "normal", action: "scroll_half_up", ctrl: true, description: "Half page up" }, + { key: "g", mode: "normal", action: "goto_top", description: "Go to top (gg)" }, + { key: "G", mode: "normal", action: "goto_bottom", shift: true, description: "Go to bottom" }, + + // Normal mode - Mode switching + { key: "i", mode: "normal", action: "enter_insert", description: "Enter insert mode" }, + { key: "a", mode: "normal", action: "enter_insert", description: "Append (enter insert)" }, + { key: ":", mode: "normal", action: "enter_command", description: "Enter command mode" }, + { key: "v", mode: "normal", action: "enter_visual", description: "Enter visual mode" }, + + // Normal mode - Search + { key: "/", mode: "normal", action: "search_start", description: "Start search" }, + { key: "n", mode: "normal", action: "search_next", description: "Next search match" }, + { key: "N", mode: "normal", action: "search_prev", shift: true, description: "Previous search match" }, + + // Normal mode - Word navigation + { key: "w", mode: "normal", action: "word_forward", description: "Next word" }, + { key: "b", mode: "normal", action: "word_backward", description: "Previous word" }, + { key: "0", mode: "normal", action: "line_start", description: "Line start" }, + { key: "$", mode: "normal", action: "line_end", description: "Line end" }, + + // Normal mode - Edit operations + { key: "y", mode: "normal", action: "yank", description: "Yank (copy)" }, + { key: "p", mode: "normal", action: "paste", description: "Paste" }, + { key: "u", mode: "normal", action: "undo", description: "Undo" }, + { key: "r", mode: "normal", action: "redo", ctrl: true, description: "Redo" }, + + // Insert mode + { key: "escape", mode: "insert", action: "exit_mode", description: "Exit to normal mode" }, + + // Command mode + { key: "escape", mode: "command", action: "cancel", description: "Cancel command" }, + { key: "return", mode: "command", action: "execute_command", description: "Execute command" }, + + // Visual mode + { key: "escape", mode: "visual", action: "exit_mode", description: "Exit visual mode" }, + { key: "y", mode: "visual", action: "yank", description: "Yank selection" }, + { key: "d", mode: "visual", action: "delete", description: "Delete selection" }, +]; + +/** + * Vim commands (: commands) + */ +export const VIM_COMMANDS = { + QUIT: "q", + QUIT_FORCE: "q!", + WRITE: "w", + WRITE_QUIT: "wq", + HELP: "help", + SET: "set", + NOHL: "nohl", + SEARCH: "/", +} as const; + +/** + * Vim command aliases + */ +export const VIM_COMMAND_ALIASES: Record = { + quit: "q", + exit: "q", + write: "w", + save: "w", + wq: "wq", + x: "wq", +}; + +/** + * Default vim configuration + */ +export const DEFAULT_VIM_CONFIG: VimConfig = { + enabled: true, + startInNormalMode: true, + showModeIndicator: true, + searchHighlights: true, +}; + +/** + * Scroll amounts + */ +export const VIM_SCROLL_AMOUNTS = { + /** Lines to scroll with j/k */ + LINE: 1, + /** Lines to scroll with Ctrl+d/u */ + HALF_PAGE: 10, + /** Lines to scroll with Ctrl+f/b */ + FULL_PAGE: 20, +} as const; + +/** + * Settings key in config + */ +export const VIM_SETTINGS_KEY = "vim"; + +/** + * Escape key codes + */ +export const ESCAPE_KEYS = ["escape", "\x1b", "\u001b"]; + +/** + * Special key names + */ +export const SPECIAL_KEYS = { + ESCAPE: "escape", + RETURN: "return", + BACKSPACE: "backspace", + DELETE: "delete", + TAB: "tab", + SPACE: "space", +} as const; diff --git a/src/constants/web-search.ts b/src/constants/web-search.ts new file mode 100644 index 0000000..fead58a --- /dev/null +++ b/src/constants/web-search.ts @@ -0,0 +1,41 @@ +/** + * Web Search Tool Constants + */ + +export const WEB_SEARCH_DEFAULTS = { + MAX_RESULTS: 10, + TIMEOUT_MS: 15000, + USER_AGENT: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", +} as const; + +export const WEB_SEARCH_MESSAGES = { + SEARCHING: (query: string) => `Searching: "${query}"`, + NO_RESULTS: "No results found", + SEARCH_ERROR: (error: string) => `Search failed: ${error}`, + TIMEOUT: "Search timed out", +} as const; + +export const WEB_SEARCH_TITLES = { + SEARCHING: (query: string) => `Searching: ${query}`, + RESULTS: (count: number) => `Found ${count} result(s)`, + FAILED: "Search failed", + NO_RESULTS: "No results", +} as const; + +export const WEB_SEARCH_DESCRIPTION = `Search the web for information. + +Use this tool to: +- Find documentation +- Look up error messages +- Research libraries and APIs +- Get current information not in your training data + +Parameters: +- query: The search query string +- maxResults: Maximum number of results to return (default: 5) + +Example: +- Search for "TypeScript generics tutorial" +- Search for "React useEffect cleanup function" +- Search for "bun test framework documentation"`; diff --git a/src/interfaces/AppProps.ts b/src/interfaces/AppProps.ts index 9a99ba6..ce543c6 100644 --- a/src/interfaces/AppProps.ts +++ b/src/interfaces/AppProps.ts @@ -5,6 +5,12 @@ */ import type { AgentConfig } from "@/types/agent-config"; +import type { PastedImage } from "@/types/image"; + +export interface SubmitOptions { + /** Pasted images to include with the message */ + images?: PastedImage[]; +} export interface AppProps { /** Unique session identifier */ @@ -18,7 +24,7 @@ export interface AppProps { /** Application version */ version: string; /** Called when user submits a message */ - onSubmit: (message: string) => Promise; + onSubmit: (message: string, options?: SubmitOptions) => Promise; /** Called when user exits the application */ onExit: () => void; /** Called when user executes a slash command */ diff --git a/src/interfaces/PastedContent.ts b/src/interfaces/PastedContent.ts index 2425b4f..d563cf5 100644 --- a/src/interfaces/PastedContent.ts +++ b/src/interfaces/PastedContent.ts @@ -2,6 +2,8 @@ * Interface for tracking pasted content with virtual text */ +import type { PastedImage } from "@/types/image"; + export interface PastedContent { /** Unique identifier for the pasted block */ id: string; @@ -22,9 +24,12 @@ export interface PasteState { pastedBlocks: Map; /** Counter for generating unique IDs */ pasteCounter: number; + /** List of pasted images */ + pastedImages: PastedImage[]; } export const createInitialPasteState = (): PasteState => ({ pastedBlocks: new Map(), pasteCounter: 0, + pastedImages: [], }); diff --git a/src/prompts/system/agent.ts b/src/prompts/system/agent.ts index d2936c4..67c3799 100644 --- a/src/prompts/system/agent.ts +++ b/src/prompts/system/agent.ts @@ -181,6 +181,7 @@ You have access to these tools - use them in the EXPLORE phase: ## Search Tools (Use First) - **glob**: Find files by pattern. Use for exploring project structure. - **grep**: Search file contents. Use for finding code patterns and implementations. +- **web_search**: Search the web. Use for documentation, error messages, library info. ## File Tools - **read**: Read file contents. ALWAYS read before editing. @@ -192,6 +193,21 @@ You have access to these tools - use them in the EXPLORE phase: - **todowrite**: Track multi-step tasks. Use for complex work. - **todoread**: Check task progress. +## Web Search Guidelines +Use web_search when: +- You need documentation for a library or API +- You encounter an unfamiliar error message +- You need current information not in your training data +- The user asks about external resources + +Example: +\`\`\` + +I need to find documentation for the Bun test framework + +[Uses web_search with query "bun test framework documentation"] +\`\`\` + ## Tool Guidelines 1. **Think first**: Always output before your first tool call diff --git a/src/prompts/system/ask.ts b/src/prompts/system/ask.ts index 56a588e..5b73e33 100644 --- a/src/prompts/system/ask.ts +++ b/src/prompts/system/ask.ts @@ -193,6 +193,7 @@ Read-only tools only: - **grep**: Search file contents for patterns - **read**: Read file contents - **todo_read**: View existing task lists +- **web_search**: Search the web for documentation, error messages, library info # Tone and Style diff --git a/src/prompts/system/code-review.ts b/src/prompts/system/code-review.ts index 8f92af4..1aa075d 100644 --- a/src/prompts/system/code-review.ts +++ b/src/prompts/system/code-review.ts @@ -185,6 +185,7 @@ Read-only tools: - **grep**: Search for related code - **read**: Read file contents - **todo_read**: View task lists +- **web_search**: Search for documentation, security advisories, best practices # Tone and Style diff --git a/src/services/agent.ts b/src/services/agent.ts index 215fc3c..e2091b0 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -23,6 +23,11 @@ import { chat as providerChat } from "@providers/index"; import { getTool, getToolsForApi, refreshMCPTools } from "@tools/index"; import type { ToolContext, ToolCall, ToolResult } from "@/types/tools"; import { initializePermissions } from "@services/permissions"; +import { + loadHooks, + executePreToolUseHooks, + executePostToolUseHooks, +} from "@services/hooks-service"; import { MAX_ITERATIONS } from "@constants/agent"; import { usageStore } from "@stores/usage-store"; @@ -130,12 +135,40 @@ const callLLM = async ( }; /** - * Execute a tool call + * Execute a tool call with hook support */ const executeTool = async ( state: AgentState, toolCall: ToolCall, ): Promise => { + // Execute PreToolUse hooks + const hookResult = await executePreToolUseHooks( + state.sessionId, + toolCall.name, + toolCall.arguments, + state.workingDir, + ); + + // Handle hook results + if (hookResult.action === "block") { + return { + success: false, + title: "Blocked by hook", + output: "", + error: hookResult.message, + }; + } + + if (hookResult.action === "warn") { + state.options.onWarning?.(hookResult.message); + } + + // Apply modified arguments if hook returned them + const effectiveArgs = + hookResult.action === "modify" + ? { ...toolCall.arguments, ...hookResult.updatedInput } + : toolCall.arguments; + const tool = getTool(toolCall.name); if (!tool) { @@ -160,19 +193,34 @@ const executeTool = async ( }, }; + let result: ToolResult; + try { // Validate arguments - const validatedArgs = tool.parameters.parse(toolCall.arguments); - return await tool.execute(validatedArgs, ctx); + const validatedArgs = tool.parameters.parse(effectiveArgs); + result = await tool.execute(validatedArgs, ctx); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); - return { + result = { success: false, title: "Tool error", output: "", error: errorMessage, }; } + + // Execute PostToolUse hooks (fire-and-forget, don't block on result) + executePostToolUseHooks( + state.sessionId, + toolCall.name, + effectiveArgs, + result, + state.workingDir, + ).catch(() => { + // Silently ignore post-hook errors + }); + + return result; }; /** @@ -190,6 +238,9 @@ export const runAgentLoop = async ( // Initialize permissions await initializePermissions(); + // Load hooks + await loadHooks(state.workingDir); + // Refresh MCP tools if available await refreshMCPTools(); diff --git a/src/services/clipboard-service.ts b/src/services/clipboard-service.ts new file mode 100644 index 0000000..31bada1 --- /dev/null +++ b/src/services/clipboard-service.ts @@ -0,0 +1,248 @@ +/** + * Clipboard Service - Reads images from system clipboard + * + * Platform-specific clipboard reading: + * - macOS: Uses pbpaste and osascript for images + * - Linux: Uses xclip or wl-paste + * - Windows: Uses PowerShell + */ + +import { spawn } from "child_process"; +import { tmpdir } from "os"; +import { join } from "path"; +import { readFile, unlink } from "fs/promises"; +import { v4 as uuidv4 } from "uuid"; +import type { ImageMediaType, PastedImage } from "@/types/image"; + +/** Supported image formats for clipboard operations */ +export const SUPPORTED_IMAGE_FORMATS: ImageMediaType[] = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +]; + +const detectPlatform = (): "darwin" | "linux" | "win32" | "unsupported" => { + const platform = process.platform; + if (platform === "darwin" || platform === "linux" || platform === "win32") { + return platform; + } + return "unsupported"; +}; + +const runCommand = ( + command: string, + args: string[], +): Promise<{ stdout: Buffer; stderr: string }> => { + return new Promise((resolve, reject) => { + const proc = spawn(command, args); + const stdout: Buffer[] = []; + let stderr = ""; + + proc.stdout.on("data", (data) => stdout.push(data)); + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve({ stdout: Buffer.concat(stdout), stderr }); + } else { + reject(new Error(`Command failed with code ${code}: ${stderr}`)); + } + }); + + proc.on("error", reject); + }); +}; + +const detectImageType = (buffer: Buffer): ImageMediaType | null => { + // PNG: 89 50 4E 47 + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) { + return "image/png"; + } + // JPEG: FF D8 FF + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return "image/jpeg"; + } + // GIF: 47 49 46 38 + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) { + return "image/gif"; + } + // WebP: 52 49 46 46 ... 57 45 42 50 + if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) { + if (buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { + return "image/webp"; + } + } + return null; +}; + +const readClipboardImageMacOS = async (): Promise => { + const tempFile = join(tmpdir(), `clipboard-${uuidv4()}.png`); + + try { + // Use osascript to save clipboard image to temp file + const script = ` + set theFile to POSIX file "${tempFile}" + try + set imageData to the clipboard as «class PNGf» + set fileRef to open for access theFile with write permission + write imageData to fileRef + close access fileRef + return "success" + on error + return "no image" + end try + `; + + const { stdout } = await runCommand("osascript", ["-e", script]); + const result = stdout.toString().trim(); + + if (result !== "success") { + return null; + } + + // Read the temp file + const imageBuffer = await readFile(tempFile); + const mediaType = detectImageType(imageBuffer); + + if (!mediaType) { + return null; + } + + const base64Data = imageBuffer.toString("base64"); + + return { + id: uuidv4(), + mediaType, + data: base64Data, + timestamp: Date.now(), + }; + } catch { + return null; + } finally { + // Cleanup temp file + try { + await unlink(tempFile); + } catch { + // Ignore cleanup errors + } + } +}; + +const readClipboardImageLinux = async (): Promise => { + // Try xclip first, then wl-paste for Wayland + const commands = [ + { cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-o"] }, + { cmd: "wl-paste", args: ["--type", "image/png"] }, + ]; + + for (const { cmd, args } of commands) { + try { + const { stdout } = await runCommand(cmd, args); + + if (stdout.length === 0) { + continue; + } + + const mediaType = detectImageType(stdout); + if (!mediaType) { + continue; + } + + return { + id: uuidv4(), + mediaType, + data: stdout.toString("base64"), + timestamp: Date.now(), + }; + } catch { + // Try next command + continue; + } + } + + return null; +}; + +const readClipboardImageWindows = async (): Promise => { + const tempFile = join(tmpdir(), `clipboard-${uuidv4()}.png`); + + try { + // PowerShell script to save clipboard image + const script = ` + Add-Type -AssemblyName System.Windows.Forms + $image = [System.Windows.Forms.Clipboard]::GetImage() + if ($image -ne $null) { + $image.Save('${tempFile.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png) + Write-Output "success" + } else { + Write-Output "no image" + } + `; + + const { stdout } = await runCommand("powershell", ["-Command", script]); + const result = stdout.toString().trim(); + + if (result !== "success") { + return null; + } + + const imageBuffer = await readFile(tempFile); + const mediaType = detectImageType(imageBuffer); + + if (!mediaType) { + return null; + } + + return { + id: uuidv4(), + mediaType, + data: imageBuffer.toString("base64"), + timestamp: Date.now(), + }; + } catch { + return null; + } finally { + try { + await unlink(tempFile); + } catch { + // Ignore cleanup errors + } + } +}; + +export const readClipboardImage = async (): Promise => { + const platform = detectPlatform(); + + const platformHandlers: Record Promise> = { + darwin: readClipboardImageMacOS, + linux: readClipboardImageLinux, + win32: readClipboardImageWindows, + unsupported: async () => null, + }; + + const handler = platformHandlers[platform]; + return handler(); +}; + +export const hasClipboardImage = async (): Promise => { + const image = await readClipboardImage(); + return image !== null; +}; + +export const formatImageSize = (bytes: number): string => { + if (bytes < 1024) { + return `${bytes}B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +}; + +export const getImageSizeFromBase64 = (base64: string): number => { + // Base64 encoding increases size by ~33% + return Math.ceil((base64.length * 3) / 4); +}; diff --git a/src/services/hooks-service.ts b/src/services/hooks-service.ts new file mode 100644 index 0000000..f73cfdb --- /dev/null +++ b/src/services/hooks-service.ts @@ -0,0 +1,446 @@ +/** + * Hooks Service + * + * Manages lifecycle hooks for tool execution and session events + */ + +import { spawn } from "child_process"; +import { readFile, access, constants } from "fs/promises"; +import { join, isAbsolute, resolve } from "path"; +import type { + HookDefinition, + HooksConfig, + HookEventType, + HookResult, + HookInput, + PreToolUseHookInput, + PostToolUseHookInput, + HookExecutionError, +} from "@/types/hooks"; +import type { ToolResult } from "@/types/tools"; +import { + HOOKS_CONFIG_FILE, + DEFAULT_HOOK_TIMEOUT, + HOOK_EXIT_CODES, + HOOK_SHELL, + MAX_HOOK_OUTPUT_SIZE, + HOOK_ENV_PREFIX, +} from "@constants/hooks"; +import { DIRS, LOCAL_CONFIG_DIR } from "@constants/paths"; + +/** + * Cached hooks configuration + */ +interface HooksCache { + global: HookDefinition[]; + local: HookDefinition[]; + loaded: boolean; +} + +const hooksCache: HooksCache = { + global: [], + local: [], + loaded: false, +}; + +/** + * Load hooks configuration from a file + */ +const loadHooksFromFile = async (filePath: string): Promise => { + try { + await access(filePath, constants.R_OK); + const content = await readFile(filePath, "utf-8"); + const config: HooksConfig = JSON.parse(content); + + if (!Array.isArray(config.hooks)) { + return []; + } + + return config.hooks.filter( + (hook) => hook.enabled !== false && hook.event && hook.script + ); + } catch { + return []; + } +}; + +/** + * Load all hooks from global and local configurations + */ +export const loadHooks = async (workingDir: string): Promise => { + const globalPath = join(DIRS.config, HOOKS_CONFIG_FILE); + const localPath = join(workingDir, LOCAL_CONFIG_DIR, HOOKS_CONFIG_FILE); + + const [globalHooks, localHooks] = await Promise.all([ + loadHooksFromFile(globalPath), + loadHooksFromFile(localPath), + ]); + + hooksCache.global = globalHooks; + hooksCache.local = localHooks; + hooksCache.loaded = true; +}; + +/** + * Refresh hooks cache + */ +export const refreshHooks = async (workingDir: string): Promise => { + hooksCache.loaded = false; + await loadHooks(workingDir); +}; + +/** + * Get hooks for a specific event type + */ +export const getHooksForEvent = (event: HookEventType): HookDefinition[] => { + if (!hooksCache.loaded) { + return []; + } + + const allHooks = [...hooksCache.global, ...hooksCache.local]; + return allHooks.filter((hook) => hook.event === event); +}; + +/** + * Resolve script path to absolute path + */ +const resolveScriptPath = (script: string, workingDir: string): string => { + if (isAbsolute(script)) { + return script; + } + return resolve(workingDir, script); +}; + +/** + * Execute a single hook script + */ +const executeHookScript = async ( + hook: HookDefinition, + input: HookInput, + workingDir: string +): Promise => { + const scriptPath = resolveScriptPath(hook.script, workingDir); + const timeout = hook.timeout ?? DEFAULT_HOOK_TIMEOUT; + + // Verify script exists + try { + await access(scriptPath, constants.X_OK); + } catch { + return { + action: "warn", + message: `Hook script not found or not executable: ${scriptPath}`, + }; + } + + return new Promise((resolvePromise) => { + const env = { + ...process.env, + [`${HOOK_ENV_PREFIX}EVENT`]: hook.event, + [`${HOOK_ENV_PREFIX}WORKING_DIR`]: workingDir, + }; + + const child = spawn(HOOK_SHELL, [scriptPath], { + cwd: workingDir, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let outputSize = 0; + + const timeoutId = setTimeout(() => { + child.kill("SIGTERM"); + resolvePromise({ + action: "warn", + message: `Hook timed out after ${timeout}ms: ${hook.name || hook.script}`, + }); + }, timeout); + + child.stdout.on("data", (data: Buffer) => { + outputSize += data.length; + if (outputSize <= MAX_HOOK_OUTPUT_SIZE) { + stdout += data.toString(); + } + }); + + child.stderr.on("data", (data: Buffer) => { + outputSize += data.length; + if (outputSize <= MAX_HOOK_OUTPUT_SIZE) { + stderr += data.toString(); + } + }); + + child.on("close", (code) => { + clearTimeout(timeoutId); + + const exitCode = code ?? HOOK_EXIT_CODES.ALLOW; + + if (exitCode === HOOK_EXIT_CODES.ALLOW) { + // Check if stdout contains modified input + if (stdout.trim()) { + try { + const parsed = JSON.parse(stdout.trim()); + if (parsed.updatedInput) { + resolvePromise({ + action: "modify", + updatedInput: parsed.updatedInput, + }); + return; + } + } catch { + // Not JSON or no updatedInput, just allow + } + } + resolvePromise({ action: "allow" }); + } else if (exitCode === HOOK_EXIT_CODES.WARN) { + resolvePromise({ + action: "warn", + message: stderr.trim() || `Hook warning: ${hook.name || hook.script}`, + }); + } else if (exitCode === HOOK_EXIT_CODES.BLOCK) { + resolvePromise({ + action: "block", + message: stderr.trim() || `Blocked by hook: ${hook.name || hook.script}`, + }); + } else { + resolvePromise({ + action: "warn", + message: `Hook exited with unexpected code ${exitCode}: ${hook.name || hook.script}`, + }); + } + }); + + child.on("error", (error) => { + clearTimeout(timeoutId); + resolvePromise({ + action: "warn", + message: `Hook execution error: ${error.message}`, + }); + }); + + // Send input to stdin + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + }); +}; + +/** + * Execute all hooks for a specific event + */ +const executeHooks = async ( + event: HookEventType, + input: HookInput, + workingDir: string +): Promise => { + const hooks = getHooksForEvent(event); + + if (hooks.length === 0) { + return { action: "allow" }; + } + + const errors: HookExecutionError[] = []; + let modifiedInput: Record | null = null; + + for (const hook of hooks) { + const result = await executeHookScript(hook, input, workingDir); + + if (result.action === "block") { + return result; + } + + if (result.action === "warn") { + errors.push({ + hook, + error: result.message, + }); + } + + if (result.action === "modify") { + modifiedInput = { + ...(modifiedInput ?? {}), + ...result.updatedInput, + }; + } + } + + if (modifiedInput) { + return { + action: "modify", + updatedInput: modifiedInput, + }; + } + + if (errors.length > 0) { + return { + action: "warn", + message: errors.map((e) => e.error).join("\n"), + }; + } + + return { action: "allow" }; +}; + +/** + * Execute PreToolUse hooks + */ +export const executePreToolUseHooks = async ( + sessionId: string, + toolName: string, + toolArgs: Record, + workingDir: string +): Promise => { + if (!hooksCache.loaded) { + await loadHooks(workingDir); + } + + const input: PreToolUseHookInput = { + sessionId, + toolName, + toolArgs, + workingDir, + }; + + return executeHooks("PreToolUse", input, workingDir); +}; + +/** + * Execute PostToolUse hooks + */ +export const executePostToolUseHooks = async ( + sessionId: string, + toolName: string, + toolArgs: Record, + result: ToolResult, + workingDir: string +): Promise => { + if (!hooksCache.loaded) { + await loadHooks(workingDir); + } + + const input: PostToolUseHookInput = { + sessionId, + toolName, + toolArgs, + result: { + success: result.success, + output: result.output, + error: result.error, + }, + workingDir, + }; + + // PostToolUse hooks don't block, just execute them + await executeHooks("PostToolUse", input, workingDir); +}; + +/** + * Execute SessionStart hooks + */ +export const executeSessionStartHooks = async ( + sessionId: string, + workingDir: string, + provider: string, + model: string +): Promise => { + if (!hooksCache.loaded) { + await loadHooks(workingDir); + } + + const input = { + sessionId, + workingDir, + provider, + model, + }; + + await executeHooks("SessionStart", input, workingDir); +}; + +/** + * Execute SessionEnd hooks + */ +export const executeSessionEndHooks = async ( + sessionId: string, + workingDir: string, + duration: number, + messageCount: number +): Promise => { + if (!hooksCache.loaded) { + await loadHooks(workingDir); + } + + const input = { + sessionId, + workingDir, + duration, + messageCount, + }; + + await executeHooks("SessionEnd", input, workingDir); +}; + +/** + * Execute UserPromptSubmit hooks + */ +export const executeUserPromptSubmitHooks = async ( + sessionId: string, + prompt: string, + workingDir: string +): Promise => { + if (!hooksCache.loaded) { + await loadHooks(workingDir); + } + + const input = { + sessionId, + prompt, + workingDir, + }; + + return executeHooks("UserPromptSubmit", input, workingDir); +}; + +/** + * Execute Stop hooks + */ +export const executeStopHooks = async ( + sessionId: string, + workingDir: string, + reason: "interrupt" | "complete" | "error" +): Promise => { + if (!hooksCache.loaded) { + await loadHooks(workingDir); + } + + const input = { + sessionId, + workingDir, + reason, + }; + + await executeHooks("Stop", input, workingDir); +}; + +/** + * Check if hooks are loaded + */ +export const isHooksLoaded = (): boolean => { + return hooksCache.loaded; +}; + +/** + * Get all loaded hooks + */ +export const getAllHooks = (): HookDefinition[] => { + return [...hooksCache.global, ...hooksCache.local]; +}; + +/** + * Clear hooks cache + */ +export const clearHooksCache = (): void => { + hooksCache.global = []; + hooksCache.local = []; + hooksCache.loaded = false; +}; diff --git a/src/services/lsp/client.ts b/src/services/lsp/client.ts new file mode 100644 index 0000000..028ed5b --- /dev/null +++ b/src/services/lsp/client.ts @@ -0,0 +1,431 @@ +/** + * LSP Client Implementation + * + * Handles LSP protocol communication with language servers + */ + +import type { ChildProcess } from "child_process"; +import { createInterface } from "readline"; +import { EventEmitter } from "events"; +import { getLanguageId } from "@services/lsp/language"; + +export interface Position { + line: number; + character: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export interface Location { + uri: string; + range: Range; +} + +export interface Diagnostic { + range: Range; + severity?: 1 | 2 | 3 | 4; // Error, Warning, Info, Hint + code?: string | number; + source?: string; + message: string; +} + +export interface CompletionItem { + label: string; + kind?: number; + detail?: string; + documentation?: string | { kind: string; value: string }; + insertText?: string; +} + +export interface DocumentSymbol { + name: string; + kind: number; + range: Range; + selectionRange: Range; + children?: DocumentSymbol[]; +} + +export interface Hover { + contents: string | { kind: string; value: string } | Array; + range?: Range; +} + +export interface LSPClientInfo { + serverId: string; + root: string; + capabilities: Record; +} + +export interface LSPClientEvents { + diagnostics: (uri: string, diagnostics: Diagnostic[]) => void; + error: (error: Error) => void; + close: () => void; +} + +type RequestId = number; + +interface PendingRequest { + resolve: (result: unknown) => void; + reject: (error: Error) => void; +} + +export class LSPClient extends EventEmitter { + private process: ChildProcess; + private serverId: string; + private root: string; + private requestId: RequestId = 0; + private pendingRequests: Map = new Map(); + private initialized: boolean = false; + private capabilities: Record = {}; + private openFiles: Map = new Map(); // uri -> version + private diagnosticsMap: Map = new Map(); + private buffer: string = ""; + + constructor(process: ChildProcess, serverId: string, root: string) { + super(); + this.process = process; + this.serverId = serverId; + this.root = root; + + this.setupHandlers(); + } + + private setupHandlers(): void { + const rl = createInterface({ + input: this.process.stdout!, + crlfDelay: Infinity, + }); + + let contentLength = 0; + let headers = true; + + rl.on("line", (line) => { + if (headers) { + if (line.startsWith("Content-Length:")) { + contentLength = parseInt(line.slice(15).trim(), 10); + } else if (line === "") { + headers = false; + this.buffer = ""; + } + } else { + this.buffer += line; + if (this.buffer.length >= contentLength) { + try { + const message = JSON.parse(this.buffer); + this.handleMessage(message); + } catch { + // Ignore parse errors + } + headers = true; + contentLength = 0; + this.buffer = ""; + } + } + }); + + this.process.on("close", () => { + this.emit("close"); + }); + + this.process.on("error", (err) => { + this.emit("error", err); + }); + } + + private handleMessage(message: { + id?: RequestId; + method?: string; + result?: unknown; + error?: { code: number; message: string }; + params?: unknown; + }): void { + // Response to our request + if (message.id !== undefined && this.pendingRequests.has(message.id)) { + const pending = this.pendingRequests.get(message.id)!; + this.pendingRequests.delete(message.id); + + if (message.error) { + pending.reject(new Error(message.error.message)); + } else { + pending.resolve(message.result); + } + return; + } + + // Notification from server + if (message.method) { + this.handleNotification(message.method, message.params); + } + } + + private handleNotification(method: string, params: unknown): void { + if (method === "textDocument/publishDiagnostics") { + const { uri, diagnostics } = params as { uri: string; diagnostics: Diagnostic[] }; + this.diagnosticsMap.set(uri, diagnostics); + this.emit("diagnostics", uri, diagnostics); + } + // Handle other notifications as needed + } + + private send(message: Record): void { + const content = JSON.stringify(message); + const header = `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n`; + this.process.stdin!.write(header + content); + } + + private async request(method: string, params?: unknown): Promise { + const id = ++this.requestId; + + return new Promise((resolve, reject) => { + this.pendingRequests.set(id, { + resolve: resolve as (result: unknown) => void, + reject, + }); + + this.send({ + jsonrpc: "2.0", + id, + method, + params, + }); + + // Timeout after 30 seconds + setTimeout(() => { + if (this.pendingRequests.has(id)) { + this.pendingRequests.delete(id); + reject(new Error(`Request ${method} timed out`)); + } + }, 30000); + }); + } + + private notify(method: string, params?: unknown): void { + this.send({ + jsonrpc: "2.0", + method, + params, + }); + } + + async initialize(): Promise { + if (this.initialized) return; + + const result = await this.request<{ capabilities: Record }>("initialize", { + processId: process.pid, + rootUri: `file://${this.root}`, + rootPath: this.root, + capabilities: { + textDocument: { + synchronization: { + didSave: true, + didOpen: true, + didClose: true, + didChange: 2, // Incremental + }, + completion: { + completionItem: { + snippetSupport: true, + documentationFormat: ["markdown", "plaintext"], + }, + }, + hover: { + contentFormat: ["markdown", "plaintext"], + }, + definition: { + linkSupport: true, + }, + references: {}, + documentSymbol: { + hierarchicalDocumentSymbolSupport: true, + }, + publishDiagnostics: { + relatedInformation: true, + }, + }, + workspace: { + workspaceFolders: true, + didChangeConfiguration: { + dynamicRegistration: true, + }, + }, + }, + workspaceFolders: [ + { + uri: `file://${this.root}`, + name: this.root.split("/").pop(), + }, + ], + }); + + this.capabilities = result.capabilities; + this.initialized = true; + + this.notify("initialized", {}); + } + + async openFile(filePath: string, content: string): Promise { + const uri = `file://${filePath}`; + const languageId = getLanguageId(filePath) ?? "plaintext"; + const version = 1; + + this.openFiles.set(uri, version); + + this.notify("textDocument/didOpen", { + textDocument: { + uri, + languageId, + version, + text: content, + }, + }); + } + + async updateFile(filePath: string, content: string): Promise { + const uri = `file://${filePath}`; + const currentVersion = this.openFiles.get(uri) ?? 0; + const newVersion = currentVersion + 1; + + this.openFiles.set(uri, newVersion); + + this.notify("textDocument/didChange", { + textDocument: { uri, version: newVersion }, + contentChanges: [{ text: content }], + }); + } + + async closeFile(filePath: string): Promise { + const uri = `file://${filePath}`; + this.openFiles.delete(uri); + this.diagnosticsMap.delete(uri); + + this.notify("textDocument/didClose", { + textDocument: { uri }, + }); + } + + async getHover(filePath: string, position: Position): Promise { + const uri = `file://${filePath}`; + + try { + return await this.request("textDocument/hover", { + textDocument: { uri }, + position, + }); + } catch { + return null; + } + } + + async getDefinition(filePath: string, position: Position): Promise { + const uri = `file://${filePath}`; + + try { + return await this.request("textDocument/definition", { + textDocument: { uri }, + position, + }); + } catch { + return null; + } + } + + async getReferences(filePath: string, position: Position, includeDeclaration = true): Promise { + const uri = `file://${filePath}`; + + try { + const result = await this.request("textDocument/references", { + textDocument: { uri }, + position, + context: { includeDeclaration }, + }); + return result ?? []; + } catch { + return []; + } + } + + async getCompletions(filePath: string, position: Position): Promise { + const uri = `file://${filePath}`; + + try { + const result = await this.request<{ items: CompletionItem[] } | CompletionItem[] | null>( + "textDocument/completion", + { + textDocument: { uri }, + position, + }, + ); + + if (!result) return []; + return Array.isArray(result) ? result : result.items; + } catch { + return []; + } + } + + async getDocumentSymbols(filePath: string): Promise { + const uri = `file://${filePath}`; + + try { + const result = await this.request("textDocument/documentSymbol", { + textDocument: { uri }, + }); + return result ?? []; + } catch { + return []; + } + } + + getDiagnostics(filePath?: string): Diagnostic[] { + if (filePath) { + const uri = `file://${filePath}`; + return this.diagnosticsMap.get(uri) ?? []; + } + + // Return all diagnostics + const all: Diagnostic[] = []; + for (const diagnostics of this.diagnosticsMap.values()) { + all.push(...diagnostics); + } + return all; + } + + getAllDiagnostics(): Map { + return new Map(this.diagnosticsMap); + } + + getInfo(): LSPClientInfo { + return { + serverId: this.serverId, + root: this.root, + capabilities: this.capabilities, + }; + } + + isFileOpen(filePath: string): boolean { + const uri = `file://${filePath}`; + return this.openFiles.has(uri); + } + + shutdown(): void { + this.request("shutdown", null) + .then(() => { + this.notify("exit"); + this.process.kill(); + }) + .catch(() => { + this.process.kill(); + }); + } +} + +export const createLSPClient = ( + process: ChildProcess, + serverId: string, + root: string, +): LSPClient => { + return new LSPClient(process, serverId, root); +}; diff --git a/src/services/lsp/index.ts b/src/services/lsp/index.ts new file mode 100644 index 0000000..76fc173 --- /dev/null +++ b/src/services/lsp/index.ts @@ -0,0 +1,357 @@ +/** + * LSP Service - Main entry point for language server functionality + * + * Provides: + * - Language detection + * - Server startup/shutdown management + * - Real-time diagnostics + * - Code completion + * - Document symbols + * - References finding + * - Definition jumping + * - Hover information + */ + +import fs from "fs/promises"; +import path from "path"; +import { EventEmitter } from "events"; +import { + LSPClient, + createLSPClient, + type Diagnostic, + type Position, + type Location, + type CompletionItem, + type DocumentSymbol, + type Hover, +} from "@services/lsp/client"; +import { + getServersForFile, + findRootForServer, + spawnServer, + type ServerInfo, +} from "@services/lsp/server"; +import { getLanguageId } from "@services/lsp/language"; + +interface LSPState { + clients: Map; // key: `${root}:${serverId}` + spawning: Map>; + broken: Set; +} + +const state: LSPState = { + clients: new Map(), + spawning: new Map(), + broken: new Set(), +}; + +const events = new EventEmitter(); + +const getClientKey = (root: string, serverId: string): string => { + return `${root}:${serverId}`; +}; + +const getClientsForFile = async (filePath: string): Promise => { + const servers = getServersForFile(filePath); + const clients: LSPClient[] = []; + + for (const server of servers) { + const root = await findRootForServer(filePath, server); + if (!root) continue; + + const key = getClientKey(root, server.id); + + // Skip broken servers + if (state.broken.has(key)) continue; + + // Check for existing client + if (state.clients.has(key)) { + clients.push(state.clients.get(key)!); + continue; + } + + // Check for in-flight spawn + if (state.spawning.has(key)) { + const client = await state.spawning.get(key); + if (client) clients.push(client); + continue; + } + + // Spawn new client + const spawnPromise = spawnClient(server, root); + state.spawning.set(key, spawnPromise); + + try { + const client = await spawnPromise; + if (client) { + clients.push(client); + } + } finally { + state.spawning.delete(key); + } + } + + return clients; +}; + +const spawnClient = async ( + server: ServerInfo, + root: string, +): Promise => { + const key = getClientKey(root, server.id); + + try { + const handle = await spawnServer(server, root); + if (!handle) { + state.broken.add(key); + return null; + } + + const client = createLSPClient(handle.process, server.id, root); + + client.on("close", () => { + state.clients.delete(key); + events.emit("clientClosed", { serverId: server.id, root }); + }); + + client.on("error", () => { + state.clients.delete(key); + state.broken.add(key); + }); + + client.on("diagnostics", (uri: string, diagnostics: Diagnostic[]) => { + events.emit("diagnostics", { uri, diagnostics, serverId: server.id }); + }); + + await client.initialize(); + state.clients.set(key, client); + + events.emit("clientConnected", { serverId: server.id, root }); + + return client; + } catch { + state.broken.add(key); + return null; + } +}; + +// Public API + +export const openFile = async (filePath: string): Promise => { + const absolutePath = path.resolve(filePath); + const clients = await getClientsForFile(absolutePath); + + if (clients.length === 0) return; + + const content = await fs.readFile(absolutePath, "utf-8"); + + for (const client of clients) { + if (!client.isFileOpen(absolutePath)) { + await client.openFile(absolutePath, content); + } + } +}; + +export const updateFile = async (filePath: string, content: string): Promise => { + const absolutePath = path.resolve(filePath); + const clients = await getClientsForFile(absolutePath); + + for (const client of clients) { + if (client.isFileOpen(absolutePath)) { + await client.updateFile(absolutePath, content); + } else { + await client.openFile(absolutePath, content); + } + } +}; + +export const closeFile = async (filePath: string): Promise => { + const absolutePath = path.resolve(filePath); + const clients = await getClientsForFile(absolutePath); + + for (const client of clients) { + if (client.isFileOpen(absolutePath)) { + await client.closeFile(absolutePath); + } + } +}; + +export const getHover = async (filePath: string, position: Position): Promise => { + const absolutePath = path.resolve(filePath); + const clients = await getClientsForFile(absolutePath); + + for (const client of clients) { + const hover = await client.getHover(absolutePath, position); + if (hover) return hover; + } + + return null; +}; + +export const getDefinition = async ( + filePath: string, + position: Position, +): Promise => { + const absolutePath = path.resolve(filePath); + const clients = await getClientsForFile(absolutePath); + + for (const client of clients) { + const definition = await client.getDefinition(absolutePath, position); + if (definition) return definition; + } + + return null; +}; + +export const getReferences = async ( + filePath: string, + position: Position, + includeDeclaration = true, +): Promise => { + const absolutePath = path.resolve(filePath); + const clients = await getClientsForFile(absolutePath); + + const allRefs: Location[] = []; + for (const client of clients) { + const refs = await client.getReferences(absolutePath, position, includeDeclaration); + allRefs.push(...refs); + } + + // Deduplicate by URI and range + const seen = new Set(); + return allRefs.filter((loc) => { + const key = `${loc.uri}:${loc.range.start.line}:${loc.range.start.character}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +export const getCompletions = async ( + filePath: string, + position: Position, +): Promise => { + const absolutePath = path.resolve(filePath); + const clients = await getClientsForFile(absolutePath); + + const allCompletions: CompletionItem[] = []; + for (const client of clients) { + const completions = await client.getCompletions(absolutePath, position); + allCompletions.push(...completions); + } + + return allCompletions; +}; + +export const getDocumentSymbols = async (filePath: string): Promise => { + const absolutePath = path.resolve(filePath); + const clients = await getClientsForFile(absolutePath); + + for (const client of clients) { + const symbols = await client.getDocumentSymbols(absolutePath); + if (symbols.length > 0) return symbols; + } + + return []; +}; + +export const getDiagnostics = (filePath?: string): Map => { + const allDiagnostics = new Map(); + + for (const client of state.clients.values()) { + const clientDiagnostics = client.getAllDiagnostics(); + for (const [uri, diagnostics] of clientDiagnostics) { + if (filePath) { + const expectedUri = `file://${path.resolve(filePath)}`; + if (uri !== expectedUri) continue; + } + + const existing = allDiagnostics.get(uri) ?? []; + allDiagnostics.set(uri, [...existing, ...diagnostics]); + } + } + + return allDiagnostics; +}; + +export const getStatus = (): { + connected: Array<{ serverId: string; root: string }>; + broken: string[]; +} => { + const connected = Array.from(state.clients.values()).map((client) => client.getInfo()); + const broken = Array.from(state.broken); + + return { connected, broken }; +}; + +export const hasSupport = (filePath: string): boolean => { + const servers = getServersForFile(filePath); + return servers.length > 0; +}; + +export const getLanguage = (filePath: string): string | null => { + return getLanguageId(filePath); +}; + +export const shutdown = (): void => { + for (const client of state.clients.values()) { + client.shutdown(); + } + state.clients.clear(); + state.spawning.clear(); + state.broken.clear(); +}; + +export const onDiagnostics = ( + callback: (data: { uri: string; diagnostics: Diagnostic[]; serverId: string }) => void, +): (() => void) => { + events.on("diagnostics", callback); + return () => events.off("diagnostics", callback); +}; + +export const onClientConnected = ( + callback: (data: { serverId: string; root: string }) => void, +): (() => void) => { + events.on("clientConnected", callback); + return () => events.off("clientConnected", callback); +}; + +export const onClientClosed = ( + callback: (data: { serverId: string; root: string }) => void, +): (() => void) => { + events.on("clientClosed", callback); + return () => events.off("clientClosed", callback); +}; + +export const lspService = { + openFile, + updateFile, + closeFile, + getHover, + getDefinition, + getReferences, + getCompletions, + getDocumentSymbols, + getDiagnostics, + getStatus, + hasSupport, + getLanguage, + shutdown, + onDiagnostics, + onClientConnected, + onClientClosed, +}; + +// Re-export types +export type { + Diagnostic, + Position, + Range, + Location, + CompletionItem, + DocumentSymbol, + Hover, +} from "@services/lsp/client"; + +export { getLanguageId, getSupportedExtensions } from "@services/lsp/language"; +export { SERVERS, getAvailableServers } from "@services/lsp/server"; diff --git a/src/services/lsp/language.ts b/src/services/lsp/language.ts new file mode 100644 index 0000000..05ff15b --- /dev/null +++ b/src/services/lsp/language.ts @@ -0,0 +1,182 @@ +/** + * Language Detection and Extension Mapping + * + * Maps file extensions to LSP language IDs + */ + +export const LANGUAGE_EXTENSIONS: Record = { + // TypeScript/JavaScript + ".ts": "typescript", + ".tsx": "typescriptreact", + ".js": "javascript", + ".jsx": "javascriptreact", + ".mjs": "javascript", + ".cjs": "javascript", + ".mts": "typescript", + ".cts": "typescript", + + // Web + ".html": "html", + ".htm": "html", + ".css": "css", + ".scss": "scss", + ".sass": "sass", + ".less": "less", + ".vue": "vue", + ".svelte": "svelte", + ".astro": "astro", + + // Python + ".py": "python", + ".pyi": "python", + ".pyw": "python", + + // Go + ".go": "go", + ".mod": "go.mod", + ".sum": "go.sum", + + // Rust + ".rs": "rust", + + // C/C++ + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".cxx": "cpp", + ".cc": "cpp", + ".hpp": "cpp", + ".hxx": "cpp", + ".hh": "cpp", + + // Java/Kotlin + ".java": "java", + ".kt": "kotlin", + ".kts": "kotlin", + + // C#/F# + ".cs": "csharp", + ".fs": "fsharp", + ".fsx": "fsharp", + + // Ruby + ".rb": "ruby", + ".rake": "ruby", + ".gemspec": "ruby", + + // PHP + ".php": "php", + + // Swift + ".swift": "swift", + + // Lua + ".lua": "lua", + + // Shell + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".fish": "fish", + + // Data formats + ".json": "json", + ".jsonc": "jsonc", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".xml": "xml", + + // Markdown/Docs + ".md": "markdown", + ".mdx": "mdx", + ".rst": "restructuredtext", + + // SQL + ".sql": "sql", + + // Docker + Dockerfile: "dockerfile", + ".dockerfile": "dockerfile", + + // Config + ".env": "dotenv", + ".ini": "ini", + ".conf": "conf", + + // Elixir + ".ex": "elixir", + ".exs": "elixir", + + // Zig + ".zig": "zig", + + // Dart + ".dart": "dart", + + // Haskell + ".hs": "haskell", + ".lhs": "haskell", + + // OCaml + ".ml": "ocaml", + ".mli": "ocaml", + + // Clojure + ".clj": "clojure", + ".cljs": "clojurescript", + ".cljc": "clojure", + + // Scala + ".scala": "scala", + ".sc": "scala", + + // Erlang + ".erl": "erlang", + ".hrl": "erlang", + + // Nix + ".nix": "nix", + + // Terraform + ".tf": "terraform", + ".tfvars": "terraform", + + // Prisma + ".prisma": "prisma", + + // GraphQL + ".graphql": "graphql", + ".gql": "graphql", + + // Protobuf + ".proto": "proto", + + // Makefile + Makefile: "makefile", + ".mk": "makefile", + + // Gleam + ".gleam": "gleam", + + // Typst + ".typ": "typst", +}; + +export const getLanguageId = (filePath: string): string | null => { + const ext = filePath.includes(".") + ? "." + filePath.split(".").pop() + : filePath.split("/").pop() ?? ""; + + return LANGUAGE_EXTENSIONS[ext] ?? LANGUAGE_EXTENSIONS[filePath.split("/").pop() ?? ""] ?? null; +}; + +export const getExtensionsForLanguage = (languageId: string): string[] => { + return Object.entries(LANGUAGE_EXTENSIONS) + .filter(([_, lang]) => lang === languageId) + .map(([ext]) => ext); +}; + +export const getSupportedExtensions = (): string[] => { + return Object.keys(LANGUAGE_EXTENSIONS); +}; diff --git a/src/services/lsp/server.ts b/src/services/lsp/server.ts new file mode 100644 index 0000000..8dea474 --- /dev/null +++ b/src/services/lsp/server.ts @@ -0,0 +1,267 @@ +/** + * LSP Server Definitions + * + * Defines how to find and spawn language servers + */ + +import { spawn, execSync, type ChildProcess } from "child_process"; +import path from "path"; +import fs from "fs/promises"; + +export interface ServerHandle { + process: ChildProcess; + capabilities?: Record; +} + +export interface ServerInfo { + id: string; + name: string; + extensions: string[]; + rootPatterns: string[]; + command: string; + args?: string[]; + env?: Record; +} + +const fileExists = async (filePath: string): Promise => { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +}; + +const findProjectRoot = async ( + startDir: string, + patterns: string[], +): Promise => { + let currentDir = startDir; + const root = path.parse(currentDir).root; + + while (currentDir !== root) { + for (const pattern of patterns) { + const checkPath = path.join(currentDir, pattern); + if (await fileExists(checkPath)) { + return currentDir; + } + } + currentDir = path.dirname(currentDir); + } + + return null; +}; + +const findBinary = async (name: string): Promise => { + try { + const command = process.platform === "win32" ? `where ${name}` : `which ${name}`; + const result = execSync(command, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); + return result.trim().split("\n")[0] || null; + } catch { + return null; + } +}; + +export const SERVERS: Record = { + typescript: { + id: "typescript", + name: "TypeScript Language Server", + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + rootPatterns: ["package.json", "tsconfig.json", "jsconfig.json"], + command: "typescript-language-server", + args: ["--stdio"], + }, + deno: { + id: "deno", + name: "Deno Language Server", + extensions: [".ts", ".tsx", ".js", ".jsx"], + rootPatterns: ["deno.json", "deno.jsonc"], + command: "deno", + args: ["lsp"], + }, + python: { + id: "python", + name: "Pyright", + extensions: [".py", ".pyi"], + rootPatterns: ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"], + command: "pyright-langserver", + args: ["--stdio"], + }, + gopls: { + id: "gopls", + name: "Go Language Server", + extensions: [".go"], + rootPatterns: ["go.mod", "go.work"], + command: "gopls", + args: ["serve"], + }, + rust: { + id: "rust-analyzer", + name: "Rust Analyzer", + extensions: [".rs"], + rootPatterns: ["Cargo.toml"], + command: "rust-analyzer", + }, + clangd: { + id: "clangd", + name: "Clangd", + extensions: [".c", ".cpp", ".h", ".hpp", ".cc", ".cxx"], + rootPatterns: ["compile_commands.json", "CMakeLists.txt", ".clangd"], + command: "clangd", + }, + lua: { + id: "lua-language-server", + name: "Lua Language Server", + extensions: [".lua"], + rootPatterns: [".luarc.json", ".luarc.jsonc"], + command: "lua-language-server", + }, + bash: { + id: "bash-language-server", + name: "Bash Language Server", + extensions: [".sh", ".bash", ".zsh"], + rootPatterns: [".bashrc", ".zshrc"], + command: "bash-language-server", + args: ["start"], + }, + yaml: { + id: "yaml-language-server", + name: "YAML Language Server", + extensions: [".yaml", ".yml"], + rootPatterns: [".yamllint", ".yaml-lint.yml"], + command: "yaml-language-server", + args: ["--stdio"], + }, + json: { + id: "vscode-json-language-server", + name: "JSON Language Server", + extensions: [".json", ".jsonc"], + rootPatterns: ["package.json", "tsconfig.json"], + command: "vscode-json-language-server", + args: ["--stdio"], + }, + html: { + id: "vscode-html-language-server", + name: "HTML Language Server", + extensions: [".html", ".htm"], + rootPatterns: ["package.json", "index.html"], + command: "vscode-html-language-server", + args: ["--stdio"], + }, + css: { + id: "vscode-css-language-server", + name: "CSS Language Server", + extensions: [".css", ".scss", ".less"], + rootPatterns: ["package.json"], + command: "vscode-css-language-server", + args: ["--stdio"], + }, + eslint: { + id: "eslint", + name: "ESLint Language Server", + extensions: [".ts", ".tsx", ".js", ".jsx"], + rootPatterns: [".eslintrc", ".eslintrc.js", ".eslintrc.json", "eslint.config.js"], + command: "vscode-eslint-language-server", + args: ["--stdio"], + }, + svelte: { + id: "svelte-language-server", + name: "Svelte Language Server", + extensions: [".svelte"], + rootPatterns: ["svelte.config.js", "svelte.config.ts"], + command: "svelteserver", + args: ["--stdio"], + }, + vue: { + id: "vue-language-server", + name: "Vue Language Server", + extensions: [".vue"], + rootPatterns: ["vue.config.js", "vite.config.ts", "nuxt.config.ts"], + command: "vue-language-server", + args: ["--stdio"], + }, + prisma: { + id: "prisma-language-server", + name: "Prisma Language Server", + extensions: [".prisma"], + rootPatterns: ["schema.prisma"], + command: "prisma-language-server", + args: ["--stdio"], + }, + terraform: { + id: "terraform-ls", + name: "Terraform Language Server", + extensions: [".tf", ".tfvars"], + rootPatterns: [".terraform", "main.tf"], + command: "terraform-ls", + args: ["serve"], + }, + docker: { + id: "docker-langserver", + name: "Dockerfile Language Server", + extensions: [".dockerfile"], + rootPatterns: ["Dockerfile", "docker-compose.yml"], + command: "docker-langserver", + args: ["--stdio"], + }, +}; + +export const getServersForFile = (filePath: string): ServerInfo[] => { + const ext = "." + (filePath.split(".").pop() ?? ""); + const fileName = path.basename(filePath); + + return Object.values(SERVERS).filter((server) => { + return ( + server.extensions.includes(ext) || + server.extensions.includes(fileName) + ); + }); +}; + +export const findRootForServer = async ( + filePath: string, + server: ServerInfo, +): Promise => { + const dir = path.dirname(filePath); + return findProjectRoot(dir, server.rootPatterns); +}; + +export const spawnServer = async ( + server: ServerInfo, + root: string, +): Promise => { + const binary = await findBinary(server.command); + + if (!binary) { + return null; + } + + const proc = spawn(binary, server.args ?? [], { + cwd: root, + env: { ...process.env, ...server.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + if (!proc.pid) { + return null; + } + + return { process: proc }; +}; + +export const isServerAvailable = async (server: ServerInfo): Promise => { + const binary = await findBinary(server.command); + return binary !== null; +}; + +export const getAvailableServers = async (): Promise => { + const available: ServerInfo[] = []; + + for (const server of Object.values(SERVERS)) { + if (await isServerAvailable(server)) { + available.push(server); + } + } + + return available; +}; diff --git a/src/services/plugin-loader.ts b/src/services/plugin-loader.ts new file mode 100644 index 0000000..0c71e46 --- /dev/null +++ b/src/services/plugin-loader.ts @@ -0,0 +1,350 @@ +/** + * Plugin Loader Service + * + * Discovers and parses plugin manifests and files + */ + +import { readdir, readFile, access, constants, stat } from "fs/promises"; +import { join, extname, basename } from "path"; +import type { + PluginManifest, + PluginDiscoveryResult, + PluginToolDefinition, + PluginCommandDefinition, +} from "@/types/plugin"; +import type { HookDefinition } from "@/types/hooks"; +import { + PLUGINS_DIR, + PLUGIN_MANIFEST_FILE, + PLUGIN_SUBDIRS, + COMMAND_FILE_EXTENSION, + COMMAND_FRONTMATTER_DELIMITER, + HOOK_SCRIPT_EXTENSIONS, + MAX_PLUGINS, +} from "@constants/plugin"; +import { DIRS, LOCAL_CONFIG_DIR } from "@constants/paths"; + +/** + * Discover plugins in a directory + */ +const discoverPluginsInDir = async ( + baseDir: string +): Promise => { + const pluginsPath = join(baseDir, PLUGINS_DIR); + const results: PluginDiscoveryResult[] = []; + + try { + await access(pluginsPath, constants.R_OK); + const entries = await readdir(pluginsPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (results.length >= MAX_PLUGINS) break; + + const pluginPath = join(pluginsPath, entry.name); + const manifestPath = join(pluginPath, PLUGIN_MANIFEST_FILE); + + try { + await access(manifestPath, constants.R_OK); + results.push({ + name: entry.name, + path: pluginPath, + manifestPath, + }); + } catch { + // No manifest, skip this directory + } + } + } catch { + // Directory doesn't exist or not readable + } + + return results; +}; + +/** + * Discover all plugins from global and local directories + */ +export const discoverPlugins = async ( + workingDir: string +): Promise => { + const [globalPlugins, localPlugins] = await Promise.all([ + discoverPluginsInDir(DIRS.config), + discoverPluginsInDir(join(workingDir, LOCAL_CONFIG_DIR)), + ]); + + // Local plugins override global ones with same name + const pluginMap = new Map(); + + for (const plugin of globalPlugins) { + pluginMap.set(plugin.name, plugin); + } + + for (const plugin of localPlugins) { + pluginMap.set(plugin.name, plugin); + } + + return Array.from(pluginMap.values()); +}; + +/** + * Parse plugin manifest + */ +export const parseManifest = async ( + manifestPath: string +): Promise => { + try { + const content = await readFile(manifestPath, "utf-8"); + const manifest: PluginManifest = JSON.parse(content); + + // Validate required fields + if (!manifest.name || !manifest.version) { + return null; + } + + return manifest; + } catch { + return null; + } +}; + +/** + * Parse command file with frontmatter + */ +export const parseCommandFile = async ( + filePath: string +): Promise => { + try { + const content = await readFile(filePath, "utf-8"); + const lines = content.split("\n"); + + // Check for frontmatter + if (lines[0]?.trim() !== COMMAND_FRONTMATTER_DELIMITER) { + // No frontmatter, treat entire content as prompt + const name = basename(filePath, COMMAND_FILE_EXTENSION); + return { + name, + description: `Custom command: ${name}`, + prompt: content, + }; + } + + // Find closing frontmatter delimiter + let endIndex = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i]?.trim() === COMMAND_FRONTMATTER_DELIMITER) { + endIndex = i; + break; + } + } + + if (endIndex === -1) { + // Malformed frontmatter + return null; + } + + // Parse frontmatter as YAML-like key-value pairs + const frontmatterLines = lines.slice(1, endIndex); + const frontmatter: Record = {}; + + for (const line of frontmatterLines) { + const colonIndex = line.indexOf(":"); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + frontmatter[key] = value; + } + } + + // Rest is the prompt + const prompt = lines.slice(endIndex + 1).join("\n").trim(); + + const name = frontmatter.name || basename(filePath, COMMAND_FILE_EXTENSION); + const description = frontmatter.description || `Custom command: ${name}`; + + return { + name, + description, + prompt, + }; + } catch { + return null; + } +}; + +/** + * Load tool module dynamically + */ +export const loadToolModule = async ( + filePath: string +): Promise => { + try { + // For Bun, we can use dynamic import + const module = await import(filePath); + const toolDef = module.default || module; + + // Validate tool definition + if (!toolDef.name || !toolDef.description || !toolDef.parameters || !toolDef.execute) { + return null; + } + + return toolDef as PluginToolDefinition; + } catch { + return null; + } +}; + +/** + * Load hooks from plugin hooks directory + */ +export const loadPluginHooks = async ( + pluginPath: string, + manifest: PluginManifest +): Promise => { + const hooks: HookDefinition[] = []; + + // Load hooks from manifest + if (manifest.hooks) { + for (const hookRef of manifest.hooks) { + const scriptPath = join(pluginPath, PLUGIN_SUBDIRS.hooks, hookRef.script); + + try { + await access(scriptPath, constants.X_OK); + hooks.push({ + event: hookRef.event as HookDefinition["event"], + script: scriptPath, + timeout: hookRef.timeout, + name: `${manifest.name}:${hookRef.event}`, + }); + } catch { + // Script not found or not executable + } + } + } + + // Also discover hooks by convention + const hooksDir = join(pluginPath, PLUGIN_SUBDIRS.hooks); + + try { + await access(hooksDir, constants.R_OK); + const entries = await readdir(hooksDir); + + for (const entry of entries) { + const ext = extname(entry); + if (!HOOK_SCRIPT_EXTENSIONS.includes(ext)) continue; + + const scriptPath = join(hooksDir, entry); + const scriptStat = await stat(scriptPath); + + if (!scriptStat.isFile()) continue; + + // Try to determine event type from filename + const baseName = basename(entry, ext); + const eventTypes = [ + "PreToolUse", + "PostToolUse", + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "Stop", + ]; + + for (const eventType of eventTypes) { + if (baseName.toLowerCase().includes(eventType.toLowerCase())) { + // Check if already added from manifest + const alreadyAdded = hooks.some( + (h) => h.script === scriptPath && h.event === eventType + ); + + if (!alreadyAdded) { + hooks.push({ + event: eventType as HookDefinition["event"], + script: scriptPath, + name: `${manifest.name}:${baseName}`, + }); + } + break; + } + } + } + } catch { + // Hooks directory doesn't exist + } + + return hooks; +}; + +/** + * Load commands from plugin commands directory + */ +export const loadPluginCommands = async ( + pluginPath: string, + manifest: PluginManifest +): Promise> => { + const commands = new Map(); + + // Load commands from manifest + if (manifest.commands) { + for (const cmdRef of manifest.commands) { + const cmdPath = join(pluginPath, PLUGIN_SUBDIRS.commands, cmdRef.file); + const cmdDef = await parseCommandFile(cmdPath); + + if (cmdDef) { + cmdDef.name = cmdRef.name; // Override with manifest name + commands.set(cmdRef.name, cmdDef); + } + } + } + + // Also discover commands by convention + const commandsDir = join(pluginPath, PLUGIN_SUBDIRS.commands); + + try { + await access(commandsDir, constants.R_OK); + const entries = await readdir(commandsDir); + + for (const entry of entries) { + if (extname(entry) !== COMMAND_FILE_EXTENSION) continue; + + const cmdPath = join(commandsDir, entry); + const cmdStat = await stat(cmdPath); + + if (!cmdStat.isFile()) continue; + + const cmdDef = await parseCommandFile(cmdPath); + + if (cmdDef && !commands.has(cmdDef.name)) { + commands.set(cmdDef.name, cmdDef); + } + } + } catch { + // Commands directory doesn't exist + } + + return commands; +}; + +/** + * Load tools from plugin tools directory + */ +export const loadPluginTools = async ( + pluginPath: string, + manifest: PluginManifest +): Promise> => { + const tools = new Map(); + + // Load tools from manifest + if (manifest.tools) { + for (const toolRef of manifest.tools) { + const toolPath = join(pluginPath, PLUGIN_SUBDIRS.tools, toolRef.file); + const toolDef = await loadToolModule(toolPath); + + if (toolDef) { + toolDef.name = toolRef.name; // Override with manifest name + tools.set(toolRef.name, toolDef); + } + } + } + + return tools; +}; diff --git a/src/services/plugin-service.ts b/src/services/plugin-service.ts new file mode 100644 index 0000000..308069b --- /dev/null +++ b/src/services/plugin-service.ts @@ -0,0 +1,278 @@ +/** + * Plugin Service + * + * Manages plugin lifecycle and provides access to plugin tools and commands + */ + +import type { + LoadedPlugin, + PluginRegistry, + PluginCommandDefinition, + PluginLoadResult, +} from "@/types/plugin"; +import type { FunctionDefinition, ToolDefinition } from "@tools/types"; +import type { HookDefinition } from "@/types/hooks"; +import { + discoverPlugins, + parseManifest, + loadPluginTools, + loadPluginCommands, + loadPluginHooks, +} from "@services/plugin-loader"; +import { + PLUGIN_TOOL_SEPARATOR, + PLUGIN_ERRORS, +} from "@constants/plugin"; + +/** + * Plugin registry singleton + */ +const registry: PluginRegistry = { + plugins: new Map(), + tools: new Map(), + commands: new Map(), + initialized: false, +}; + +/** + * Load a single plugin + */ +const loadPlugin = async ( + _name: string, + path: string, + manifestPath: string +): Promise => { + const manifest = await parseManifest(manifestPath); + + if (!manifest) { + return { + success: false, + error: PLUGIN_ERRORS.MANIFEST_INVALID, + }; + } + + const [tools, commands, hooks] = await Promise.all([ + loadPluginTools(path, manifest), + loadPluginCommands(path, manifest), + loadPluginHooks(path, manifest), + ]); + + const plugin: LoadedPlugin = { + manifest, + path, + tools, + commands, + hooks, + enabled: true, + }; + + return { + success: true, + plugin, + }; +}; + +/** + * Initialize the plugin system + */ +export const initializePlugins = async (workingDir: string): Promise => { + if (registry.initialized) { + return; + } + + const discoveredPlugins = await discoverPlugins(workingDir); + + for (const discovered of discoveredPlugins) { + const result = await loadPlugin( + discovered.name, + discovered.path, + discovered.manifestPath + ); + + if (result.success && result.plugin) { + registry.plugins.set(discovered.name, result.plugin); + + // Register tools with prefixed names + for (const [toolName, toolDef] of result.plugin.tools) { + const prefixedName = `${discovered.name}${PLUGIN_TOOL_SEPARATOR}${toolName}`; + registry.tools.set(prefixedName, toolDef); + } + + // Register commands + for (const [cmdName, cmdDef] of result.plugin.commands) { + registry.commands.set(cmdName, cmdDef); + } + } + } + + registry.initialized = true; +}; + +/** + * Refresh plugins (reload all) + */ +export const refreshPlugins = async (workingDir: string): Promise => { + registry.plugins.clear(); + registry.tools.clear(); + registry.commands.clear(); + registry.initialized = false; + + await initializePlugins(workingDir); +}; + +/** + * Check if a tool is a plugin tool + */ +export const isPluginTool = (name: string): boolean => { + return registry.tools.has(name); +}; + +/** + * Get a plugin tool by name + */ +export const getPluginTool = (name: string): ToolDefinition | undefined => { + const pluginTool = registry.tools.get(name); + + if (!pluginTool) { + return undefined; + } + + return pluginTool as unknown as ToolDefinition; +}; + +/** + * Get all plugin tools for API + */ +export const getPluginToolsForApi = (): { + type: "function"; + function: FunctionDefinition; +}[] => { + const tools: { + type: "function"; + function: FunctionDefinition; + }[] = []; + + for (const [name, tool] of registry.tools) { + tools.push({ + type: "function", + function: { + name, + description: tool.description, + parameters: { + type: "object", + properties: {}, + }, + }, + }); + } + + return tools; +}; + +/** + * Get a plugin command by name + */ +export const getPluginCommand = ( + name: string +): PluginCommandDefinition | undefined => { + return registry.commands.get(name); +}; + +/** + * Check if a command is a plugin command + */ +export const isPluginCommand = (name: string): boolean => { + return registry.commands.has(name); +}; + +/** + * Get all plugin commands + */ +export const getAllPluginCommands = (): PluginCommandDefinition[] => { + return Array.from(registry.commands.values()); +}; + +/** + * Get all plugin hooks + */ +export const getAllPluginHooks = (): HookDefinition[] => { + const hooks: HookDefinition[] = []; + + for (const plugin of registry.plugins.values()) { + if (plugin.enabled) { + hooks.push(...plugin.hooks); + } + } + + return hooks; +}; + +/** + * Get all loaded plugins + */ +export const getAllPlugins = (): LoadedPlugin[] => { + return Array.from(registry.plugins.values()); +}; + +/** + * Get a specific plugin by name + */ +export const getPlugin = (name: string): LoadedPlugin | undefined => { + return registry.plugins.get(name); +}; + +/** + * Enable a plugin + */ +export const enablePlugin = (name: string): boolean => { + const plugin = registry.plugins.get(name); + + if (!plugin) { + return false; + } + + plugin.enabled = true; + return true; +}; + +/** + * Disable a plugin + */ +export const disablePlugin = (name: string): boolean => { + const plugin = registry.plugins.get(name); + + if (!plugin) { + return false; + } + + plugin.enabled = false; + return true; +}; + +/** + * Check if plugins are initialized + */ +export const isPluginsInitialized = (): boolean => { + return registry.initialized; +}; + +/** + * Get plugin count + */ +export const getPluginCount = (): number => { + return registry.plugins.size; +}; + +/** + * Get plugin tool count + */ +export const getPluginToolCount = (): number => { + return registry.tools.size; +}; + +/** + * Get plugin command count + */ +export const getPluginCommandCount = (): number => { + return registry.commands.size; +}; diff --git a/src/services/project-setup-service.ts b/src/services/project-setup-service.ts new file mode 100644 index 0000000..21147b5 --- /dev/null +++ b/src/services/project-setup-service.ts @@ -0,0 +1,392 @@ +/** + * Project Setup Service + * + * Automatically configures the project on startup: + * - Adds .codetyper to .gitignore if .git exists + * - Creates default agent configurations in .codetyper/agents/ + */ + +import fs from "fs/promises"; +import path from "path"; + +const CODETYPER_DIR = ".codetyper"; +const AGENTS_DIR = "agents"; +const GITIGNORE_ENTRY = ".codetyper/"; + +interface SetupResult { + gitignoreUpdated: boolean; + agentsCreated: string[]; + errors: string[]; +} + +const fileExists = async (filePath: string): Promise => { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +}; + +const isGitRepository = async (workingDir: string): Promise => { + return fileExists(path.join(workingDir, ".git")); +}; + +const ensureDirectoryExists = async (dirPath: string): Promise => { + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch { + // Directory might already exist + } +}; + +const addToGitignore = async (workingDir: string): Promise => { + const gitignorePath = path.join(workingDir, ".gitignore"); + + try { + let content = ""; + const exists = await fileExists(gitignorePath); + + if (exists) { + content = await fs.readFile(gitignorePath, "utf-8"); + + // Check if already present + const lines = content.split("\n").map((line) => line.trim()); + if (lines.includes(GITIGNORE_ENTRY) || lines.includes(CODETYPER_DIR)) { + return false; // Already configured + } + } + + // Add .codetyper to gitignore + const newContent = content.endsWith("\n") || content === "" + ? `${content}${GITIGNORE_ENTRY}\n` + : `${content}\n${GITIGNORE_ENTRY}\n`; + + await fs.writeFile(gitignorePath, newContent, "utf-8"); + return true; + } catch { + return false; + } +}; + +interface AgentDefinition { + id: string; + name: string; + description: string; + mode: "primary" | "subagent" | "all"; + color: string; + prompt: string; +} + +const DEFAULT_AGENTS: AgentDefinition[] = [ + { + id: "explore", + name: "Explore", + description: "Fast codebase exploration specialist", + mode: "subagent", + color: "cyan", + prompt: `You are an expert codebase explorer. Your role is to quickly navigate and understand codebases. + +## Capabilities +- Find files by patterns and naming conventions +- Search code for keywords, functions, classes, and patterns +- Answer questions about codebase structure and architecture +- Identify key files and entry points + +## Guidelines +- Use Glob to find files by pattern +- Use Grep to search file contents +- Use Read to examine specific files +- Be thorough but efficient - explore multiple locations +- Report findings with exact file paths and line numbers +- Summarize patterns and conventions you discover + +## Output Format +Always include: +1. Files found (with paths) +2. Relevant code snippets +3. Summary of findings +4. Suggestions for further exploration if needed`, + }, + { + id: "plan", + name: "Plan", + description: "Software architect for designing implementation plans", + mode: "subagent", + color: "yellow", + prompt: `You are a software architect specializing in implementation planning. + +## Role +Design comprehensive implementation plans for features, refactors, and bug fixes. + +## Approach +1. Analyze requirements thoroughly +2. Explore relevant codebase areas +3. Identify affected components +4. Consider architectural trade-offs +5. Create step-by-step implementation plans + +## Output Format +Your plans should include: + +### Summary +Brief overview of the change + +### Affected Files +List of files that will be created, modified, or deleted + +### Implementation Steps +1. Step-by-step instructions +2. Each step should be atomic and testable +3. Include code snippets where helpful + +### Considerations +- Potential risks or edge cases +- Testing requirements +- Performance implications +- Backwards compatibility + +### Dependencies +Any prerequisites or blocking tasks`, + }, + { + id: "bash", + name: "Bash", + description: "Command execution specialist for terminal operations", + mode: "subagent", + color: "green", + prompt: `You are a command execution specialist for terminal operations. + +## Expertise +- Git operations (commit, push, branch, rebase, etc.) +- Package management (npm, yarn, pnpm, pip, etc.) +- Build tools and scripts +- System commands +- Docker and container operations + +## Guidelines +- Always explain what commands will do before executing +- Use safe defaults and avoid destructive operations +- Quote paths with spaces properly +- Handle errors gracefully +- Provide clear output and status + +## Safety Rules +- NEVER run destructive commands without explicit confirmation +- NEVER modify git config without permission +- NEVER force push to main/master +- NEVER skip safety hooks unless requested +- Always check command exit codes + +## Output +Include: +- Command being executed +- Expected outcome +- Actual output or error +- Next steps if needed`, + }, + { + id: "code-reviewer", + name: "Code Reviewer", + description: "Expert code reviewer for quality and best practices", + mode: "subagent", + color: "magenta", + prompt: `You are an expert code reviewer focused on quality and best practices. + +## Review Areas +1. **Correctness** - Does the code do what it's supposed to? +2. **Security** - Are there vulnerabilities or unsafe patterns? +3. **Performance** - Are there inefficiencies or bottlenecks? +4. **Maintainability** - Is the code readable and well-organized? +5. **Testing** - Is the code testable and properly tested? + +## Review Process +1. Understand the change's purpose +2. Check for correctness and edge cases +3. Look for security issues (OWASP top 10) +4. Assess code style and conventions +5. Verify error handling +6. Check test coverage + +## Output Format +### Summary +Brief assessment of the change + +### Issues Found +- **Critical**: Must fix before merge +- **Major**: Should fix, significant impact +- **Minor**: Nice to fix, low impact +- **Nitpick**: Style/preference suggestions + +### Positive Aspects +What's done well + +### Suggestions +Specific improvements with code examples`, + }, + { + id: "architect", + name: "Code Architect", + description: "Design implementation plans and architectural decisions", + mode: "subagent", + color: "blue", + prompt: `You are a code architect specializing in system design and implementation strategy. + +## Responsibilities +- Design scalable and maintainable solutions +- Make architectural decisions with clear trade-offs +- Create implementation roadmaps +- Identify patterns and anti-patterns + +## Approach +1. **Understand Context** + - Current system architecture + - Constraints and requirements + - Team capabilities and preferences + +2. **Explore Options** + - Consider multiple approaches + - Evaluate trade-offs + - Document pros and cons + +3. **Design Solution** + - Clear component structure + - Interface definitions + - Data flow diagrams + - Integration points + +4. **Plan Implementation** + - Phased approach if needed + - Risk mitigation + - Testing strategy + +## Output +- Architecture overview +- Component breakdown +- Interface contracts +- Implementation phases +- Risk assessment`, + }, + { + id: "general", + name: "General Purpose", + description: "Multi-step research and complex task execution", + mode: "subagent", + color: "white", + prompt: `You are a general-purpose agent for researching complex questions and executing multi-step tasks. + +## Capabilities +- Search codebases for information +- Read and analyze files +- Execute multi-step research tasks +- Synthesize findings from multiple sources +- Answer complex questions about code + +## Approach +1. Break down complex tasks into steps +2. Gather information systematically +3. Cross-reference findings +4. Synthesize and summarize + +## Guidelines +- Be thorough in research +- Cite sources with file paths and line numbers +- Acknowledge uncertainty when present +- Provide actionable insights + +## Output +- Clear, structured answers +- Supporting evidence +- Confidence level +- Further research suggestions if needed`, + }, +]; + +const generateAgentFile = (agent: AgentDefinition): string => { + return `--- +name: "${agent.name}" +description: "${agent.description}" +mode: "${agent.mode}" +color: "${agent.color}" +--- + +${agent.prompt} +`; +}; + +const createDefaultAgents = async (workingDir: string): Promise => { + const agentsDir = path.join(workingDir, CODETYPER_DIR, AGENTS_DIR); + const created: string[] = []; + + await ensureDirectoryExists(agentsDir); + + for (const agent of DEFAULT_AGENTS) { + const filePath = path.join(agentsDir, `${agent.id}.agent.md`); + + // Skip if already exists + if (await fileExists(filePath)) { + continue; + } + + try { + const content = generateAgentFile(agent); + await fs.writeFile(filePath, content, "utf-8"); + created.push(agent.id); + } catch { + // Skip on error + } + } + + return created; +}; + +export const setupProject = async (workingDir: string): Promise => { + const result: SetupResult = { + gitignoreUpdated: false, + agentsCreated: [], + errors: [], + }; + + try { + // Check if this is a git repository + const isGit = await isGitRepository(workingDir); + + if (isGit) { + // Add .codetyper to gitignore + result.gitignoreUpdated = await addToGitignore(workingDir); + } + + // Create default agents + result.agentsCreated = await createDefaultAgents(workingDir); + } catch (error) { + result.errors.push(error instanceof Error ? error.message : String(error)); + } + + return result; +}; + +export const getSetupStatus = async (workingDir: string): Promise<{ + hasGit: boolean; + hasCodetyperDir: boolean; + agentCount: number; +}> => { + const hasGit = await isGitRepository(workingDir); + const hasCodetyperDir = await fileExists(path.join(workingDir, CODETYPER_DIR)); + + let agentCount = 0; + if (hasCodetyperDir) { + const agentsDir = path.join(workingDir, CODETYPER_DIR, AGENTS_DIR); + if (await fileExists(agentsDir)) { + const files = await fs.readdir(agentsDir); + agentCount = files.filter((f) => f.endsWith(".agent.md")).length; + } + } + + return { hasGit, hasCodetyperDir, agentCount }; +}; + +export const projectSetupService = { + setupProject, + getSetupStatus, + isGitRepository, +}; diff --git a/src/services/security-service.ts b/src/services/security-service.ts new file mode 100644 index 0000000..75664fb --- /dev/null +++ b/src/services/security-service.ts @@ -0,0 +1,347 @@ +/** + * Security Service - Pattern detection and validation + * + * Provides: + * - Command injection detection + * - XSS pattern detection + * - Permission explainer + * - Shell continuation validation + * - OAuth token filtering + * - Security pattern hooks + */ + +export type SecurityRisk = "critical" | "high" | "medium" | "low" | "info"; + +export interface SecurityIssue { + type: string; + risk: SecurityRisk; + description: string; + location?: string; + suggestion?: string; +} + +export interface SecurityReport { + issues: SecurityIssue[]; + hasCritical: boolean; + hasHigh: boolean; + summary: string; +} + +// Command injection patterns +const COMMAND_INJECTION_PATTERNS = [ + // Shell metacharacters + { pattern: /[;&|`$]/, description: "Shell metacharacter detected" }, + // Subshell execution + { pattern: /\$\([^)]+\)/, description: "Subshell execution detected" }, + // Backtick execution + { pattern: /`[^`]+`/, description: "Backtick command execution detected" }, + // Pipe chains + { pattern: /\|(?!\|)/, description: "Pipe character detected" }, + // Redirections + { pattern: /[<>]/, description: "Redirection operator detected" }, + // Newline injection + { pattern: /[\n\r]/, description: "Newline character in command" }, + // Null byte injection + { pattern: /\x00/, description: "Null byte detected" }, + // Environment variable expansion + { pattern: /\$\{[^}]+\}/, description: "Environment variable expansion" }, + { pattern: /\$[A-Za-z_][A-Za-z0-9_]*/, description: "Variable reference detected" }, +]; + +// XSS patterns +const XSS_PATTERNS = [ + // Script tags + { pattern: /]/i, description: "Script tag detected" }, + // Event handlers + { pattern: /on\w+\s*=/i, description: "Event handler attribute detected" }, + // JavaScript protocol + { pattern: /javascript:/i, description: "JavaScript protocol detected" }, + // Data URLs with script content + { pattern: /data:[^,]*;base64/i, description: "Data URL with base64 encoding" }, + // Expression/eval + { pattern: /expression\s*\(/i, description: "CSS expression detected" }, + // SVG with script + { pattern: /].*?