diff --git a/README.md b/README.md index 2ba484a..d8ab139 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ DevBridge turns your Telegram into a powerful developer control panel. Chat with - 🤖 **AI Chat via Telegram** -- Send messages to Claude CLI or Gemini CLI and get responses right in Telegram - 📂 **Multi-Project Support** -- Switch between multiple codebases on the fly with `/project` -- 🔒 **Security First** -- Chat ID whitelist, command sandbox with whitelist-only execution, read-only AI tools +- 🔒 **Security First** -- Chat ID whitelist, command sandbox with whitelist-only execution, configurable AI permission levels (`readonly`, `read-write`, `full`) - 🔌 **Plugin System** -- Extend functionality with built-in or custom plugins (Git, GitHub, and more) - 🏃 **Run Commands** -- Execute pre-approved shell commands (`/run test`, `/run build`) safely - 💬 **Session Management** -- Persistent conversation sessions per project with automatic TTL cleanup @@ -72,12 +72,14 @@ DevBridge is configured via `devbridge.config.json` in your working directory. R "path": "/absolute/path/to/project", // Absolute path to project root "adapter": "claude", // "claude" or "gemini" "model": "sonnet", // Optional: model override - "description": "My main app" // Optional: shown in /projects + "description": "My main app", // Optional: shown in /projects + "permission_level": "read-write" // Optional: "readonly" (default), "read-write", or "full" }, "api-backend": { "path": "/absolute/path/to/api", "adapter": "gemini", "description": "Backend API" + // permission_level defaults to "readonly" } }, @@ -100,7 +102,9 @@ DevBridge is configured via `devbridge.config.json` in your working directory. R "defaults": { "adapter": "claude", // Default adapter for new projects "model": "sonnet", // Default model (adapter-specific) - "timeout": 120, // AI response timeout in seconds + "timeout": 120, // AI response timeout in seconds (non-streaming) + "stream_timeout": 3600, // Hard max seconds for streaming responses (1 hour) + "inactivity_timeout": 300, // Kill streaming process after N seconds of no output "max_message_length": 4096, // Telegram message chunk size "session_ttl_hours": 24, // Auto-cleanup inactive sessions "command_timeout": 60 // /run command timeout in seconds @@ -181,7 +185,7 @@ See [docs/configuration.md](docs/configuration.md) for detailed documentation of ### Chat -Any message that is not a command is sent to the AI agent (Claude or Gemini) as a conversation prompt. The AI operates within your active project's directory and can read files using safe, read-only tools. +Any message that is not a command is sent to the AI agent (Claude or Gemini) as a conversation prompt. The AI operates within your active project's directory. The tools available to the AI depend on the project's `permission_level`: `readonly` (default) restricts the AI to reading files, `read-write` allows file creation and editing, and `full` grants shell and network access. See [docs/security.md](docs/security.md) for details. ## 🔌 Plugin System @@ -250,7 +254,7 @@ DevBridge is designed with multiple layers of security: 2. **Command Sandbox** -- The `/run` command only executes commands explicitly listed in the `commands` config. Commands are spawned without `shell: true` to prevent injection attacks. -3. **Read-Only AI Tools** -- The Claude adapter restricts the AI to read-only tools (`Read`, `Glob`, `Grep`), preventing the AI from modifying files when accessed via Telegram. +3. **Configurable AI Permission Levels** -- Each project has a `permission_level` (`readonly`, `read-write`, or `full`) that controls which tools the AI can use. The default `readonly` level restricts the AI to read-only tools (`Read`, `Glob`, `Grep`), preventing file modifications. Higher levels grant write or full access as needed. 4. **Webhook Signature Verification** -- GitHub webhooks are verified using HMAC-SHA256 signatures when a `secret` is configured. @@ -307,7 +311,7 @@ curl -X POST http://localhost:9876/webhook/ci \ A: Claude CLI and Gemini CLI are supported out of the box. DevBridge uses an adapter system, so new CLIs can be added by implementing the `CLIAdapter` interface. See [docs/adapters.md](docs/adapters.md). **Q: Can the AI modify my files?** -A: By default, no. The Claude adapter restricts the AI to read-only tools (`Read`, `Glob`, `Grep`). The Gemini adapter passes messages directly with no tool restrictions from DevBridge's side (restrictions depend on Gemini CLI's own configuration). +A: By default, no. Both adapters use a `permission_level` system that defaults to `readonly`, restricting the AI to read-only tools. Set `permission_level` to `"read-write"` to allow file creation and editing, or `"full"` for complete access including shell commands. See [docs/security.md](docs/security.md) for details. **Q: Is it safe to expose the notification server to the internet?** A: The server binds to `127.0.0.1` by default. If you need external access (e.g., for GitHub webhooks), use a reverse proxy with HTTPS, configure a webhook `secret` for HMAC verification, and consider firewall rules. Rate limiting is built in. diff --git a/devbridge.config.example.json b/devbridge.config.example.json index 451066d..48d20eb 100644 --- a/devbridge.config.example.json +++ b/devbridge.config.example.json @@ -8,12 +8,27 @@ "path": "/absolute/path/to/your/project", "adapter": "claude", "model": "sonnet", - "description": "My main application" + "description": "Read-only (default) - can only read code" + }, + "my-app-dev": { + "path": "/absolute/path/to/your/project", + "adapter": "claude", + "model": "sonnet", + "description": "Read + Write - can create and edit files", + "permission_level": "read-write" + }, + "my-app-full": { + "path": "/absolute/path/to/your/project", + "adapter": "claude", + "model": "opus", + "description": "Full access - agents, commands, spec-driven dev", + "permission_level": "full" }, "api-backend": { "path": "/absolute/path/to/api", "adapter": "gemini", - "description": "Backend API" + "description": "Backend API", + "permission_level": "full" } }, "commands": { @@ -30,6 +45,8 @@ "defaults": { "adapter": "claude", "timeout": 120, + "stream_timeout": 3600, + "inactivity_timeout": 300, "max_message_length": 4096, "session_ttl_hours": 24, "command_timeout": 60 diff --git a/docs/adapters.md b/docs/adapters.md index e9b35ac..0f15e76 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -21,21 +21,30 @@ interface CLIAdapter { // Check if the CLI binary is installed and accessible. // Called at startup to detect available adapters. - chat(message: string, sessionId: string, options: ChatOptions & { cwd: string }): Promise; - // Send a message to the AI and return the response text. + chat( + message: string, + sessionId: string | null, + options: ChatOptions & { cwd: string } + ): Promise; + // Send a message to the AI and return the response. // - message: the user's text from Telegram - // - sessionId: unique identifier for the conversation session + // - sessionId: unique identifier for the conversation session (null for first message) // - options.model: optional model name override // - options.timeout: max seconds to wait (default: 120) // - options.cwd: project directory to run the CLI in + // - options.allowedTools: optional comma-separated tool list override + // - options.skipPermissions: optional flag to skip interactive permission prompts - newSession(projectPath: string): string; - // Create a new session and return its ID. - // Called when the user starts a new conversation or after /clear. - - clearSession(sessionId: string): void; - // Clean up resources associated with a session. - // Called when the user runs /clear or when sessions expire. + chatStream?( + message: string, + sessionId: string | null, + options: StreamChatOptions & { cwd: string } + ): Promise; + // Optional streaming variant of chat(). Sends output chunks incrementally + // via the onChunk callback as the CLI produces them. + // - options.onChunk: callback invoked with each output chunk + // - options.minChunkSize: minimum characters to buffer before calling onChunk + // - options.inactivityTimeout: seconds of no output before killing the process } ``` @@ -47,6 +56,19 @@ type AdapterName = 'claude' | 'gemini'; // Extend this union for new adapters interface ChatOptions { model?: string; timeout?: number; + allowedTools?: string; // Comma-separated list of tools (e.g., "Read,Glob,Grep") + skipPermissions?: boolean; // Skip interactive permission prompts in the CLI +} + +interface ChatResult { + text: string; + sessionId: string | null; +} + +interface StreamChatOptions extends ChatOptions { + onChunk: (chunk: string) => void | Promise; + minChunkSize?: number; + inactivityTimeout?: number; } ``` @@ -212,9 +234,13 @@ When the user runs `/clear` or a session expires: --- -## The `spawnCLI` Utility +## The `spawnCLI` and `spawnCLIStreaming` Utilities + +DevBridge provides `src/utils/process.ts` with two functions for running CLI processes. + +### `spawnCLI` -- Buffered execution -DevBridge provides `src/utils/process.ts` with a `spawnCLI` function that handles: +Runs a CLI command and returns the full output after the process exits. Best for short-lived commands. - Process spawning with `spawn()` (no `shell: true`) - Timeout management (SIGTERM followed by SIGKILL) @@ -237,7 +263,15 @@ function spawnCLI( ): Promise; ``` -Use this utility in your adapter to get consistent behavior with the rest of the system. +### `spawnCLIStreaming` -- Streaming execution + +Runs a CLI command and streams output chunks as they arrive. Designed for long-running AI responses where you want incremental feedback. + +- **Inactivity timeout**: If the process produces no stdout/stderr for `inactivityTimeout` seconds (default: 300), it is killed. This prevents hung processes from blocking indefinitely. +- **Hard timeout**: A safety-net `streamTimeout` (default: 3600 seconds / 1 hour) kills the process regardless of activity. +- Calls `onChunk` with each output chunk as it arrives + +Use `spawnCLI` for quick operations (version checks, short prompts) and `spawnCLIStreaming` for AI chat responses that may take minutes. --- @@ -269,8 +303,9 @@ class AdapterRegistry { - **Binary**: `claude` - **Session management**: Uses `--session-id` and `--resume` flags for multi-turn conversations -- **Output format**: `--output-format text` -- **Safety**: Restricts tools to `Read,Glob,Grep` via `--allowedTools` +- **Output format**: `--output-format stream-json` (streaming mode) or `--output-format text` (non-streaming) +- **Permission control**: Tools are configured via `--allowedTools` based on the project's `permission_level` (default: `Read,Glob,Grep`). When `skipPermissions` is enabled, passes `--dangerously-skip-permissions` to auto-approve tool usage +- **Streaming**: Supports `chatStream()` via `spawnCLIStreaming` with inactivity timeout - **Session tracking**: An in-memory `Set` tracks active sessions to decide when to use `--resume` - **Error recovery**: Detects corrupted sessions from error messages and signals for automatic cleanup @@ -279,7 +314,8 @@ class AdapterRegistry { - **Binary**: `gemini` - **Session management**: Uses `-p` flag for prompts (session continuity depends on Gemini CLI capabilities) - **Output format**: Uses CLI defaults -- **Safety**: No DevBridge-side tool restrictions; relies on Gemini CLI's own safety configuration +- **Permission control**: Tools are configured via `--allowed-tools` based on the project's `permission_level`. When `skipPermissions` is enabled, passes `--yolo` to auto-approve tool usage +- **Streaming**: Supports `chatStream()` via `spawnCLIStreaming` with inactivity timeout --- diff --git a/docs/configuration.md b/docs/configuration.md index 9ca1cf3..d575250 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -55,6 +55,9 @@ A map of named projects. Each project points to a local directory and specifies | `adapter` | `"claude" \| "gemini"` | No | Value from `defaults.adapter` | Which AI CLI to use for this project | | `model` | `string` | No | Value from `defaults.model` | Model name to pass to the AI CLI (e.g., `"sonnet"`, `"opus"`) | | `description` | `string` | No | -- | Human-readable description shown in `/projects` output | +| `permission_level` | `"readonly" \| "read-write" \| "full"` | No | `"readonly"` | Controls which AI tools are available and whether permissions are auto-approved. See [security.md](security.md) for details | +| `allowed_tools` | `string` | No | Derived from `permission_level` | Explicit comma-separated list of allowed tools, overriding the permission level mapping. Advanced use only | +| `skip_permissions` | `boolean` | No | Derived from `permission_level` | If `true`, the AI CLI skips interactive permission prompts (Claude: `--dangerously-skip-permissions`, Gemini: `--yolo`). Advanced use only | ```json { @@ -63,7 +66,8 @@ A map of named projects. Each project points to a local directory and specifies "path": "/home/user/projects/frontend", "adapter": "claude", "model": "sonnet", - "description": "React frontend app" + "description": "React frontend app", + "permission_level": "read-write" }, "backend": { "path": "/home/user/projects/backend", @@ -170,7 +174,9 @@ Default values for various settings. |--------|------|---------|-------------| | `adapter` | `"claude" \| "gemini"` | `"claude"` | Default adapter when a project does not specify one | | `model` | `string` | `undefined` | Default model passed to the AI CLI. If unset, the CLI's default is used | -| `timeout` | `number` | `120` | Maximum seconds to wait for an AI response before timing out | +| `timeout` | `number` | `120` | Maximum seconds to wait for an AI response before timing out (non-streaming mode) | +| `stream_timeout` | `number` | `3600` | Hard maximum seconds for a streaming AI response. Acts as a safety net | +| `inactivity_timeout` | `number` | `300` | Seconds of no stdout/stderr output before a streaming AI process is killed | | `max_message_length` | `number` | `4096` | Maximum characters per Telegram message chunk. Responses exceeding this are split | | `session_ttl_hours` | `number` | `24` | Hours of inactivity after which a session is automatically cleaned up | | `command_timeout` | `number` | `60` | Maximum seconds for `/run` command execution | @@ -181,6 +187,8 @@ Default values for various settings. "adapter": "claude", "model": "sonnet", "timeout": 120, + "stream_timeout": 3600, + "inactivity_timeout": 300, "max_message_length": 4096, "session_ttl_hours": 24, "command_timeout": 60 @@ -261,12 +269,14 @@ When the notification server is enabled, the following endpoints are available: "path": "/home/user/projects/frontend", "adapter": "claude", "model": "sonnet", - "description": "React SPA" + "description": "React SPA", + "permission_level": "read-write" }, "backend": { "path": "/home/user/projects/backend", "adapter": "gemini", - "description": "Go API" + "description": "Go API", + "permission_level": "readonly" } }, "commands": { @@ -283,6 +293,8 @@ When the notification server is enabled, the following endpoints are available: "defaults": { "adapter": "claude", "timeout": 120, + "stream_timeout": 3600, + "inactivity_timeout": 300, "max_message_length": 4096, "session_ttl_hours": 24, "command_timeout": 60 diff --git a/docs/security.md b/docs/security.md index 7e5c828..c08cc9f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -65,32 +65,43 @@ Using `shell: true` would allow shell metacharacters (`|`, `;`, `&&`, `$()`, bac - Avoid whitelisting commands that accept user input from arguments (the alias itself is the only input) - Use specific commands rather than generic wrappers -### 3. Read-Only AI Tools +### 3. Configurable Permission Levels -**File**: `src/adapters/claude.ts` +**Files**: `src/adapters/permissions.ts`, `src/adapters/claude.ts`, `src/adapters/gemini.ts` -When DevBridge invokes Claude CLI, it restricts the AI to read-only file system tools: +Each project has a `permission_level` that controls which AI tools are available and whether interactive permission prompts are skipped. The default is `"readonly"`. -```typescript -const args = [ - '-p', message, - '--session-id', sessionId, - '--output-format', 'text', - '--allowedTools', 'Read,Glob,Grep', -]; -``` +| Level | AI Can Do | Permissions Auto-Approved | +|-------|-----------|--------------------------| +| `readonly` (default) | Read files, search files and contents | No | +| `read-write` | All of the above + create, write, and edit files | Yes | +| `full` | All of the above + execute shell commands, access the network, run agents | Yes | + +**Tool mapping per adapter**: + +| Level | Claude CLI (`--allowedTools`) | Gemini CLI (`--allowed-tools`) | +|-------|------------------------------|-------------------------------| +| `readonly` | `Read,Glob,Grep` | `ReadFileTool,ReadManyFilesTool,GlobTool,GrepTool` | +| `read-write` | `Read,Glob,Grep,Write,Edit,MultiEdit` | Above + `WriteFileTool,EditTool` | +| `full` | Above + `Bash,WebFetch,WebSearch,Agent,NotebookEdit` | Above + `ShellTool,WebFetchTool,WebSearchTool` | -**What this means**: -- Claude can **read** files (`Read` tool) -- Claude can **search** for files (`Glob` tool) -- Claude can **search** file contents (`Grep` tool) -- Claude **cannot** write, edit, or delete files -- Claude **cannot** execute shell commands -- Claude **cannot** access the network +**Permission bypass flags**: +- `read-write` and `full` levels automatically pass `--dangerously-skip-permissions` (Claude) or `--yolo` (Gemini) to avoid interactive prompts that would block the CLI process +- The `readonly` level does not skip permissions since read-only tools do not trigger prompts -This ensures that even if the AI misinterprets a request, it cannot modify your codebase or execute arbitrary code. +**Advanced overrides**: You can bypass the permission level mapping entirely by setting `allowed_tools` (explicit tool list) or `skip_permissions` (explicit bypass flag) on a project. These take precedence over the `permission_level` defaults. -**Note for Gemini adapter**: The Gemini CLI adapter (`src/adapters/gemini.ts`) passes messages directly to the CLI without DevBridge-side tool restrictions. Any restrictions depend on Gemini CLI's own safety configuration. +```json +{ + "projects": { + "my-app": { + "path": "/path/to/project", + "adapter": "claude", + "permission_level": "read-write" + } + } +} +``` ### 4. Webhook Signature Verification @@ -179,7 +190,7 @@ The `/mute` and `/notifications off` commands allow users to temporarily or perm |--------|------------| | Unauthorized Telegram user sends commands | Chat ID whitelist silently drops messages | | Arbitrary command execution via `/run` | Command whitelist + no `shell: true` | -| AI modifies or deletes files | Claude restricted to Read/Glob/Grep tools | +| AI modifies or deletes files | Default `readonly` permission level restricts tools to Read/Glob/Grep. Higher levels (`read-write`, `full`) grant write or shell access intentionally | | Forged GitHub webhooks | HMAC-SHA256 signature verification with timing-safe comparison | | Webhook flood / DDoS | Per-IP rate limiting + local bind address | | Config file exposure | `devbridge.config.json` is in `.gitignore` | @@ -198,4 +209,5 @@ The `/mute` and `/notifications off` commands allow users to temporarily or perm 5. **Use a reverse proxy** -- If exposing the notification server externally, terminate TLS at the proxy 6. **Monitor logs** -- Check `~/.devbridge/logs/devbridge.log` for unauthorized access attempts 7. **Keep CLIs updated** -- Ensure Claude CLI, Gemini CLI, and `gh` CLI are up to date with the latest security patches +8. **Use `readonly` for projects that don't need write access** -- The default `readonly` permission level is the safest option. Only escalate to `read-write` or `full` for projects where the AI genuinely needs to create or modify files. Review your permission levels periodically ]]> \ No newline at end of file diff --git a/src/adapters/claude.ts b/src/adapters/claude.ts index 8b2ba12..cf2fd8a 100644 --- a/src/adapters/claude.ts +++ b/src/adapters/claude.ts @@ -1,5 +1,5 @@ -import type { CLIAdapter, ChatOptions, ChatResult } from '../types.js'; -import { spawnCLI } from '../utils/process.js'; +import type { CLIAdapter, ChatOptions, ChatResult, StreamChatOptions } from '../types.js'; +import { spawnCLI, spawnCLIStreaming } from '../utils/process.js'; import { logger } from '../utils/logger.js'; interface ClaudeJsonResponse { @@ -8,6 +8,15 @@ interface ClaudeJsonResponse { is_error: boolean; } +interface ClaudeStreamMessage { + type: string; + message?: string; + session_id?: string; + result?: string; +} + +const DEFAULT_ALLOWED_TOOLS = 'Read,Glob,Grep'; + export class ClaudeAdapter implements CLIAdapter { name = 'claude'; @@ -19,12 +28,18 @@ export class ClaudeAdapter implements CLIAdapter { return result.exitCode === 0; } - async chat(message: string, sessionId: string | null, options: ChatOptions & { cwd: string }): Promise { - const args = [ - '-p', message, - '--output-format', 'json', - '--allowedTools', 'Read,Glob,Grep', - ]; + private buildBaseArgs( + message: string, + outputFormat: string, + options: ChatOptions, + sessionId: string | null, + ): string[] { + const tools = options.allowedTools ?? DEFAULT_ALLOWED_TOOLS; + const args = ['-p', message, '--output-format', outputFormat, '--allowedTools', tools]; + + if (options.skipPermissions) { + args.push('--dangerously-skip-permissions'); + } if (sessionId) { args.push('--resume', sessionId); @@ -34,6 +49,15 @@ export class ClaudeAdapter implements CLIAdapter { args.push('--model', options.model); } + return args; + } + + async chat( + message: string, + sessionId: string | null, + options: ChatOptions & { cwd: string }, + ): Promise { + const args = this.buildBaseArgs(message, 'json', options, sessionId); const timeout = options.timeout ?? 120; logger.debug('Claude CLI call', { sessionId, resume: !!sessionId }); @@ -44,21 +68,26 @@ export class ClaudeAdapter implements CLIAdapter { }); if (result.timedOut) { - throw new Error(`Timeout — Claude demorou mais de ${timeout}s. Tente novamente ou /clear para nova sessao.`); + throw new Error( + `Timeout — Claude demorou mais de ${timeout}s. Tente novamente ou /clear para nova sessao.`, + ); } if (result.exitCode !== 0) { const errorMsg = result.stderr || result.stdout || 'Unknown error'; logger.error('Claude CLI error', { exitCode: result.exitCode, stderr: result.stderr }); - if (errorMsg.includes('session') || errorMsg.includes('Session') || errorMsg.includes('No matching')) { + if ( + errorMsg.includes('session') || + errorMsg.includes('Session') || + errorMsg.includes('No matching') + ) { throw new Error('SESSION_EXPIRED'); } throw new Error(`Erro ao processar: ${errorMsg.slice(0, 200)}`); } - // Parse JSON response from Claude CLI try { const json: ClaudeJsonResponse = JSON.parse(result.stdout); return { @@ -66,7 +95,6 @@ export class ClaudeAdapter implements CLIAdapter { sessionId: json.session_id || null, }; } catch { - // Fallback: if JSON parsing fails, return raw stdout logger.warn('Failed to parse Claude JSON response, using raw output'); return { text: result.stdout || '(resposta vazia)', @@ -74,4 +102,107 @@ export class ClaudeAdapter implements CLIAdapter { }; } } + + async chatStream( + message: string, + sessionId: string | null, + options: StreamChatOptions & { cwd: string }, + ): Promise { + const args = this.buildBaseArgs(message, 'stream-json', options, sessionId); + args.push('--verbose'); + const timeout = options.timeout ?? 600; + + logger.debug('Claude CLI streaming call', { sessionId, resume: !!sessionId, args }); + + let fullText = ''; + let lastSessionId: string | null = sessionId; + let pendingLine = ''; + + const result = await spawnCLIStreaming('claude', args, { + cwd: options.cwd, + timeout, + inactivityTimeout: options.inactivityTimeout, + minChunkSize: options.minChunkSize, + onChunk: async (rawChunk: string) => { + const lines = (pendingLine + rawChunk).split('\n'); + pendingLine = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const obj: ClaudeStreamMessage = JSON.parse(line); + + if (obj.type === 'assistant' && obj.message) { + const newText = obj.message; + if (newText.length > fullText.length) { + const chunk = newText.slice(fullText.length); + fullText = newText; + await options.onChunk(chunk); + } + } else if (obj.type === 'result' && obj.result) { + if (obj.result.length > fullText.length) { + const chunk = obj.result.slice(fullText.length); + fullText = obj.result; + await options.onChunk(chunk); + } + } + + if (obj.session_id) { + lastSessionId = obj.session_id; + } + } catch { + // Non-JSON line, might be partial or error output + logger.debug('Non-JSON line in stream', { line: line.slice(0, 100) }); + } + } + }, + }); + + if (pendingLine.trim()) { + try { + const obj: ClaudeStreamMessage = JSON.parse(pendingLine); + if (obj.session_id) { + lastSessionId = obj.session_id; + } + if (obj.result && obj.result.length > fullText.length) { + const chunk = obj.result.slice(fullText.length); + fullText = obj.result; + await options.onChunk(chunk); + } + } catch { + // Ignore parse errors on final pending line + } + } + + if (result.timedOut) { + const inactivity = options.inactivityTimeout ?? 300; + throw new Error( + `Timeout — Claude ficou inativo por ${inactivity}s (sem produzir output). Pode ter travado. Tente novamente ou /clear para nova sessao.`, + ); + } + + if (result.exitCode !== 0) { + const errorMsg = result.stderr || result.stdout || 'Unknown error'; + logger.error('Claude CLI streaming error', { + exitCode: result.exitCode, + stderr: result.stderr, + }); + + if ( + errorMsg.includes('session') || + errorMsg.includes('Session') || + errorMsg.includes('No matching') + ) { + throw new Error('SESSION_EXPIRED'); + } + + throw new Error(`Erro ao processar: ${errorMsg.slice(0, 200)}`); + } + + return { + text: fullText || '(resposta vazia)', + sessionId: lastSessionId, + }; + } } diff --git a/src/adapters/gemini.ts b/src/adapters/gemini.ts index 90eecb6..88730b6 100644 --- a/src/adapters/gemini.ts +++ b/src/adapters/gemini.ts @@ -1,7 +1,9 @@ -import type { CLIAdapter, ChatOptions, ChatResult } from '../types.js'; -import { spawnCLI } from '../utils/process.js'; +import type { CLIAdapter, ChatOptions, ChatResult, StreamChatOptions } from '../types.js'; +import { spawnCLI, spawnCLIStreaming } from '../utils/process.js'; import { logger } from '../utils/logger.js'; +const DEFAULT_ALLOWED_TOOLS = 'ReadFileTool,GlobTool,GrepTool'; + export class GeminiAdapter implements CLIAdapter { name = 'gemini'; @@ -13,10 +15,19 @@ export class GeminiAdapter implements CLIAdapter { return result.exitCode === 0; } - async chat(message: string, sessionId: string | null, options: ChatOptions & { cwd: string }): Promise { - const args = [ - '-p', message, - ]; + private buildBaseArgs( + message: string, + options: ChatOptions, + sessionId: string | null, + ): string[] { + const args = ['-p', message]; + + const tools = options.allowedTools ?? DEFAULT_ALLOWED_TOOLS; + args.push('--allowed-tools', tools); + + if (options.skipPermissions) { + args.push('--yolo'); + } if (sessionId) { args.push('--resume', 'latest'); @@ -26,6 +37,15 @@ export class GeminiAdapter implements CLIAdapter { args.push('--model', options.model); } + return args; + } + + async chat( + message: string, + sessionId: string | null, + options: ChatOptions & { cwd: string }, + ): Promise { + const args = this.buildBaseArgs(message, options, sessionId); const timeout = options.timeout ?? 120; logger.debug('Gemini CLI call', { sessionId, resume: !!sessionId }); @@ -36,14 +56,20 @@ export class GeminiAdapter implements CLIAdapter { }); if (result.timedOut) { - throw new Error(`Timeout — Gemini demorou mais de ${timeout}s. Tente novamente ou /clear para nova sessao.`); + throw new Error( + `Timeout — Gemini demorou mais de ${timeout}s. Tente novamente ou /clear para nova sessao.`, + ); } if (result.exitCode !== 0) { const errorMsg = result.stderr || result.stdout || 'Unknown error'; logger.error('Gemini CLI error', { exitCode: result.exitCode, stderr: result.stderr }); - if (errorMsg.includes('session') || errorMsg.includes('Session') || errorMsg.includes('resume')) { + if ( + errorMsg.includes('session') || + errorMsg.includes('Session') || + errorMsg.includes('resume') + ) { throw new Error('SESSION_EXPIRED'); } @@ -55,4 +81,62 @@ export class GeminiAdapter implements CLIAdapter { sessionId: `gemini:${options.cwd}`, }; } + + async chatStream( + message: string, + sessionId: string | null, + options: StreamChatOptions & { cwd: string }, + ): Promise { + const args = this.buildBaseArgs(message, options, sessionId); + const timeout = options.timeout ?? 600; + + logger.debug('Gemini CLI streaming call', { sessionId, resume: !!sessionId }); + + let fullText = ''; + + const result = await spawnCLIStreaming('gemini', args, { + cwd: options.cwd, + timeout, + inactivityTimeout: options.inactivityTimeout, + minChunkSize: options.minChunkSize, + onChunk: async (chunk: string) => { + if (chunk.length > fullText.length) { + const newText = chunk; + const delta = newText.slice(fullText.length); + fullText = newText; + await options.onChunk(delta); + } + }, + }); + + if (result.timedOut) { + const inactivity = options.inactivityTimeout ?? 300; + throw new Error( + `Timeout — Gemini ficou inativo por ${inactivity}s (sem produzir output). Pode ter travado. Tente novamente ou /clear para nova sessao.`, + ); + } + + if (result.exitCode !== 0) { + const errorMsg = result.stderr || result.stdout || 'Unknown error'; + logger.error('Gemini CLI streaming error', { + exitCode: result.exitCode, + stderr: result.stderr, + }); + + if ( + errorMsg.includes('session') || + errorMsg.includes('Session') || + errorMsg.includes('resume') + ) { + throw new Error('SESSION_EXPIRED'); + } + + throw new Error(`Erro ao processar: ${errorMsg.slice(0, 200)}`); + } + + return { + text: fullText || result.stdout || '(resposta vazia)', + sessionId: `gemini:${options.cwd}`, + }; + } } diff --git a/src/adapters/permissions.ts b/src/adapters/permissions.ts new file mode 100644 index 0000000..688ba0b --- /dev/null +++ b/src/adapters/permissions.ts @@ -0,0 +1,57 @@ +import type { AdapterName, PermissionLevel } from '../types.js'; + +interface PermissionConfig { + allowedTools: string; + skipPermissions: boolean; +} + +const CLAUDE_TOOLS: Record = { + readonly: { + allowedTools: 'Read,Glob,Grep', + skipPermissions: false, + }, + 'read-write': { + allowedTools: 'Read,Glob,Grep,Write,Edit,MultiEdit', + skipPermissions: true, + }, + full: { + allowedTools: 'Read,Glob,Grep,Write,Edit,MultiEdit,Bash,WebFetch,WebSearch,Agent,NotebookEdit', + skipPermissions: true, + }, +}; + +const GEMINI_TOOLS: Record = { + readonly: { + allowedTools: 'ReadFileTool,ReadManyFilesTool,GlobTool,GrepTool', + skipPermissions: false, + }, + 'read-write': { + allowedTools: 'ReadFileTool,ReadManyFilesTool,GlobTool,GrepTool,WriteFileTool,EditTool', + skipPermissions: true, + }, + full: { + allowedTools: + 'ReadFileTool,ReadManyFilesTool,GlobTool,GrepTool,WriteFileTool,EditTool,ShellTool,WebFetchTool,WebSearchTool', + skipPermissions: true, + }, +}; + +const ADAPTER_TOOLS: Record> = { + claude: CLAUDE_TOOLS, + gemini: GEMINI_TOOLS, +}; + +export function resolvePermissions( + adapter: AdapterName, + permissionLevel?: PermissionLevel, + allowedToolsOverride?: string, + skipPermissionsOverride?: boolean, +): PermissionConfig { + const level = permissionLevel ?? 'readonly'; + const defaults = ADAPTER_TOOLS[adapter][level]; + + return { + allowedTools: allowedToolsOverride ?? defaults.allowedTools, + skipPermissions: skipPermissionsOverride ?? defaults.skipPermissions, + }; +} diff --git a/src/cli/init.ts b/src/cli/init.ts index d37ce33..372a3ac 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -4,6 +4,7 @@ import { homedir } from 'node:os'; import { createInterface } from 'node:readline'; import { scanForProjects } from './scanner.js'; import { spawnCLI } from '../utils/process.js'; +import type { PermissionLevel } from '../types.js'; function createRL() { return createInterface({ input: process.stdin, output: process.stdout }); @@ -73,6 +74,26 @@ async function pollForChatId(token: string, rl: ReturnType): Pr return ask(rl, ' Cole seu Chat ID manualmente: '); } +interface PermissionOption { + label: string; + level: PermissionLevel; +} + +const PERMISSION_OPTIONS: Record = { + '1': { + label: 'Somente leitura (padrao seguro)', + level: 'readonly', + }, + '2': { + label: 'Leitura + Escrita (pode criar e editar arquivos)', + level: 'read-write', + }, + '3': { + label: 'Acesso completo (leitura, escrita, comandos shell, agentes)', + level: 'full', + }, +}; + export async function init() { const rl = createRL(); @@ -80,7 +101,7 @@ export async function init() { console.log('===============\n'); // Step 1 — Detect CLIs - console.log('Passo 1/4 — Detectando CLIs de AI...'); + console.log('Passo 1/5 — Detectando CLIs de AI...'); const claudeResult = await spawnCLI('claude', ['--version'], { cwd: process.cwd(), timeout: 10 }); const geminiResult = await spawnCLI('gemini', ['--version'], { cwd: process.cwd(), timeout: 10 }); @@ -97,7 +118,7 @@ export async function init() { } // Step 2 — Bot token - console.log('\nPasso 2/4 — Telegram Bot'); + console.log('\nPasso 2/5 — Telegram Bot'); console.log(' Crie um bot no Telegram: @BotFather \u2192 /newbot'); const botToken = await ask(rl, ' Cole o token do seu bot: '); @@ -108,17 +129,22 @@ export async function init() { } // Step 3 — Chat ID (auto-discovery) - console.log('\nPasso 3/4 — Seu Chat ID'); + console.log('\nPasso 3/5 — Seu Chat ID'); const chatId = await pollForChatId(botToken.trim(), rl); // Step 4 — Projects - console.log('\nPasso 4/4 — Projetos'); + console.log('\nPasso 4/5 — Projetos'); const scanPath = await ask(rl, ` Diretorio para escanear (default: ${homedir()}/projetos): `) || join(homedir(), 'projetos'); console.log(` Escaneando ${scanPath}...`); const detected = scanForProjects(resolve(scanPath)); - const projects: Record = {}; + const projects: Record = {}; if (detected.length === 0) { console.log(' Nenhum projeto encontrado. Voce pode adicionar manualmente no config.'); @@ -144,6 +170,26 @@ export async function init() { } } + // Step 5 — Permission levels per project + if (Object.keys(projects).length > 0) { + console.log('\nPasso 5/5 — Nivel de permissao por projeto'); + console.log(' Define o que a AI pode fazer em cada projeto:\n'); + console.log(' 1) Somente leitura — apenas consulta o codigo'); + console.log(' 2) Leitura + Escrita — pode criar e editar arquivos'); + console.log(' 3) Acesso completo — leitura, escrita, comandos shell, agentes\n'); + + for (const [name, proj] of Object.entries(projects)) { + const choice = await ask(rl, ` ${name} — nivel de permissao (1/2/3, default: 1): `) || '1'; + const option = PERMISSION_OPTIONS[choice] ?? PERMISSION_OPTIONS['1']; + + if (option.level !== 'readonly') { + proj.permission_level = option.level; + } + + console.log(` → ${option.label}\n`); + } + } + // Build smart commands from detected projects const commands: Record = { status: 'git status --short', @@ -172,6 +218,8 @@ export async function init() { defaults: { adapter: hasClaudeV ? 'claude' : 'gemini', timeout: 120, + stream_timeout: 3600, + inactivity_timeout: 300, max_message_length: 4096, session_ttl_hours: 24, command_timeout: 60, diff --git a/src/config.ts b/src/config.ts index 2889b1b..351f7bc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; -import type { DevBridgeConfig, ProjectConfig } from './types.js'; +import type { DevBridgeConfig, ProjectConfig, PermissionLevel } from './types.js'; import { logger } from './utils/logger.js'; const CONFIG_FILENAME = 'devbridge.config.json'; @@ -10,7 +10,9 @@ export function loadConfig(): DevBridgeConfig { if (!existsSync(configPath)) { console.error(`Config file not found: ${configPath}`); - console.error(`Copy devbridge.config.example.json to ${CONFIG_FILENAME} and fill in your values.`); + console.error( + `Copy devbridge.config.example.json to ${CONFIG_FILENAME} and fill in your values.`, + ); process.exit(1); } @@ -31,7 +33,9 @@ export function loadConfig(): DevBridgeConfig { let projects: Record = {}; if (raw.project && !raw.projects) { - logger.warn('Config migrado automaticamente de v0.1. Atualize para o formato v0.2 com "projects".'); + logger.warn( + 'Config migrado automaticamente de v0.1. Atualize para o formato v0.2 com "projects".', + ); projects[raw.project.name] = { path: resolve(raw.project.path), adapter: raw.project.adapter ?? 'claude', @@ -45,6 +49,9 @@ export function loadConfig(): DevBridgeConfig { adapter: (p.adapter as 'claude' | 'gemini') ?? 'claude', model: p.model as string | undefined, description: p.description as string | undefined, + permission_level: p.permission_level as PermissionLevel | undefined, + allowed_tools: p.allowed_tools as string | undefined, + skip_permissions: p.skip_permissions as boolean | undefined, }; } } else { @@ -89,6 +96,8 @@ export function loadConfig(): DevBridgeConfig { adapter: raw.defaults?.adapter ?? 'claude', model: raw.defaults?.model, timeout: raw.defaults?.timeout ?? 120, + stream_timeout: raw.defaults?.stream_timeout ?? 3600, + inactivity_timeout: raw.defaults?.inactivity_timeout ?? 300, max_message_length: raw.defaults?.max_message_length ?? 4096, session_ttl_hours: raw.defaults?.session_ttl_hours ?? 24, command_timeout: raw.defaults?.command_timeout ?? 60, diff --git a/src/router.ts b/src/router.ts index 619df9b..5914eb3 100644 --- a/src/router.ts +++ b/src/router.ts @@ -2,23 +2,117 @@ import type { Context } from 'grammy'; import type { SessionManager } from './sessions/manager.js'; import type { StateManager } from './state.js'; import type { AdapterRegistry } from './adapters/index.js'; -import type { DevBridgeConfig, ChatResult } from './types.js'; +import type { DevBridgeConfig, ChatResult, CLIAdapter, StreamChatOptions } from './types.js'; +import { resolvePermissions } from './adapters/permissions.js'; import { splitMessage, sendWithMarkdown, withTypingIndicator } from './utils/telegram.js'; import { logger } from './utils/logger.js'; const CONTEXT_WARNING_THRESHOLD = 50; +const STREAM_MIN_CHUNK_SIZE = 100; export function createChatHandler( sessionManager: SessionManager, stateManager: StateManager, registry: AdapterRegistry, - config: DevBridgeConfig + config: DevBridgeConfig, ) { + function buildStreamOptions( + ctx: Context, + message: string, + sessionId: string | null, + projectPath: string, + model: string | undefined, + allowedTools: string | undefined, + skipPermissions: boolean | undefined, + ): StreamChatOptions & { cwd: string } { + return { + model, + timeout: config.defaults.stream_timeout, + inactivityTimeout: config.defaults.inactivity_timeout, + allowedTools, + skipPermissions, + cwd: projectPath, + minChunkSize: STREAM_MIN_CHUNK_SIZE, + onChunk: async (chunk: string) => { + if (!chunk.trim()) return; + + const chunks = splitMessage(chunk, config.defaults.max_message_length); + for (const textChunk of chunks) { + try { + await ctx.reply(textChunk); + } catch (err) { + logger.warn('Failed to send chunk', { error: (err as Error).message }); + } + } + }, + }; + } + + async function sendWithStreaming( + ctx: Context, + adapter: CLIAdapter, + message: string, + sessionId: string | null, + projectPath: string, + model: string | undefined, + allowedTools: string | undefined, + skipPermissions: boolean | undefined, + ): Promise { + return await withTypingIndicator(ctx, async () => { + const chatStream = adapter.chatStream; + if (!chatStream) { + throw new Error('chatStream not available'); + } + const opts = buildStreamOptions( + ctx, + message, + sessionId, + projectPath, + model, + allowedTools, + skipPermissions, + ); + return await chatStream.call(adapter, message, sessionId, opts); + }); + } + + async function sendWithoutStreaming( + ctx: Context, + adapter: CLIAdapter, + message: string, + sessionId: string | null, + projectPath: string, + model: string | undefined, + allowedTools: string | undefined, + skipPermissions: boolean | undefined, + ): Promise { + return await withTypingIndicator(ctx, () => + adapter.chat(message, sessionId, { + model, + timeout: config.defaults.timeout, + allowedTools, + skipPermissions, + cwd: projectPath, + }), + ); + } + return async (ctx: Context) => { const message = ctx.message?.text; if (!message) return; const chatId = ctx.chat?.id?.toString() ?? ''; + const username = ctx.from?.username ?? 'unknown'; + const userId = ctx.from?.id?.toString() ?? 'unknown'; + + logger.info('Message received', { + chatId, + userId, + username, + messageLength: message.length, + message: message.slice(0, 100), + }); + const activeProject = stateManager.getActiveProject(chatId); if (!activeProject) { @@ -32,42 +126,64 @@ export function createChatHandler( return; } + const startTime = Date.now(); + try { - const session = sessionManager.getOrCreate( - activeProject, - project.path, - project.adapter - ); + const session = sessionManager.getOrCreate(activeProject, project.path, project.adapter); const adapter = registry.get(project.adapter); const model = project.model ?? config.defaults.model; + const hasStreaming = adapter.chatStream !== undefined; + const permissions = resolvePermissions( + project.adapter, + project.permission_level, + project.allowed_tools, + project.skip_permissions, + ); + const { allowedTools, skipPermissions } = permissions; + + const sendFn = hasStreaming ? sendWithStreaming : sendWithoutStreaming; + + logger.debug('Processing message', { + adapter: project.adapter, + hasStreaming, + project: activeProject, + hasSession: !!session.cliSessionId, + skipPermissions: !!skipPermissions, + allowedTools: allowedTools ?? 'default', + }); let result: ChatResult; try { - result = await withTypingIndicator(ctx, () => - adapter.chat(message, session.cliSessionId, { - model, - timeout: config.defaults.timeout, - cwd: project.path, - }) + result = await sendFn( + ctx, + adapter, + message, + session.cliSessionId, + project.path, + model, + allowedTools, + skipPermissions, ); } catch (err) { const errorMsg = (err as Error).message; - // Auto-recovery: if session expired and we had a CLI session, retry without it if (errorMsg === 'SESSION_EXPIRED' && session.cliSessionId) { logger.info('Session expired, auto-recovering', { project: activeProject }); await ctx.reply('Sessao anterior expirou. Reiniciando conversa...'); sessionManager.update(session.id, { cliSessionId: null }); - result = await withTypingIndicator(ctx, () => - adapter.chat(message, null, { - model, - timeout: config.defaults.timeout, - cwd: project.path, - }) + result = await sendFn( + ctx, + adapter, + message, + null, + project.path, + model, + allowedTools, + skipPermissions, ); } else { throw err; @@ -80,18 +196,41 @@ export function createChatHandler( lastMessageAt: new Date().toISOString(), }); - // Context warning + const durationMs = Date.now() - startTime; + const responseLength = result.text.length; + + logger.info('Message processed successfully', { + chatId, + project: activeProject, + adapter: project.adapter, + streaming: hasStreaming, + durationMs, + responseLength, + messageCount: session.messageCount + 1, + }); + if (session.messageCount + 1 === CONTEXT_WARNING_THRESHOLD) { - await ctx.reply(`Aviso: ${CONTEXT_WARNING_THRESHOLD} mensagens nesta sessao. Use /clear para comecar do zero se necessario.`); + await ctx.reply( + `Aviso: ${CONTEXT_WARNING_THRESHOLD} mensagens nesta sessao. Use /clear para comecar do zero se necessario.`, + ); } - const chunks = splitMessage(result.text, config.defaults.max_message_length); - for (const chunk of chunks) { - await sendWithMarkdown(ctx, chunk); + if (!hasStreaming) { + const chunks = splitMessage(result.text, config.defaults.max_message_length); + for (const chunk of chunks) { + await sendWithMarkdown(ctx, chunk); + } } } catch (err) { const errorMessage = (err as Error).message; - logger.error('Chat handler error', { error: errorMessage }); + const durationMs = Date.now() - startTime; + + logger.error('Chat handler error', { + chatId, + project: activeProject, + error: errorMessage, + durationMs, + }); if (errorMessage === 'SESSION_EXPIRED') { sessionManager.clearByProject(activeProject); diff --git a/src/types.ts b/src/types.ts index 691781c..7e7c6f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,14 @@ export type AdapterName = 'claude' | 'gemini'; +export type PermissionLevel = 'readonly' | 'read-write' | 'full'; export interface ProjectConfig { path: string; adapter: AdapterName; model?: string; description?: string; + permission_level?: PermissionLevel; + allowed_tools?: string; + skip_permissions?: boolean; } export interface DevBridgeConfig { @@ -27,6 +31,8 @@ export interface DevBridgeConfig { adapter: AdapterName; model?: string; timeout: number; + stream_timeout: number; + inactivity_timeout: number; max_message_length: number; session_ttl_hours: number; command_timeout: number; @@ -48,6 +54,8 @@ export interface DevBridgeConfig { export interface ChatOptions { model?: string; timeout?: number; + allowedTools?: string; + skipPermissions?: boolean; } export interface ChatResult { @@ -55,10 +63,25 @@ export interface ChatResult { sessionId: string | null; } +export interface StreamChatOptions extends ChatOptions { + onChunk: (chunk: string) => void | Promise; + minChunkSize?: number; + inactivityTimeout?: number; +} + export interface CLIAdapter { name: string; isAvailable(): Promise; - chat(message: string, sessionId: string | null, options: ChatOptions & { cwd: string }): Promise; + chat( + message: string, + sessionId: string | null, + options: ChatOptions & { cwd: string }, + ): Promise; + chatStream?( + message: string, + sessionId: string | null, + options: StreamChatOptions & { cwd: string }, + ): Promise; } export interface Session { diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 4950c3d..5cf0971 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,12 @@ -import { appendFileSync, mkdirSync, existsSync } from 'node:fs'; +import { + appendFileSync, + mkdirSync, + existsSync, + statSync, + renameSync, + unlinkSync, + readdirSync, +} from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -6,6 +14,8 @@ type LogLevel = 'debug' | 'info' | 'warn' | 'error'; const LOG_DIR = join(homedir(), '.devbridge', 'logs'); const LOG_FILE = join(LOG_DIR, 'devbridge.log'); +const MAX_LOG_SIZE_MB = 10; +const MAX_LOG_FILES = 5; const LEVEL_PRIORITY: Record = { debug: 0, @@ -22,6 +32,33 @@ function ensureLogDir() { } } +function rotateLogsIfNeeded() { + try { + const stats = statSync(LOG_FILE); + const sizeMB = stats.size / (1024 * 1024); + + if (sizeMB >= MAX_LOG_SIZE_MB) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const rotatedFile = join(LOG_DIR, `devbridge-${timestamp}.log`); + + renameSync(LOG_FILE, rotatedFile); + + const files = readdirSync(LOG_DIR) + .filter((f) => f.startsWith('devbridge-') && f.endsWith('.log')) + .sort() + .reverse(); + + files.slice(MAX_LOG_FILES).forEach((f) => { + try { + unlinkSync(join(LOG_DIR, f)); + } catch {} + }); + } + } catch { + // File doesn't exist yet + } +} + function formatMessage(level: LogLevel, message: string, meta?: unknown): string { const timestamp = new Date().toISOString(); const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''; @@ -48,6 +85,7 @@ function log(level: LogLevel, message: string, meta?: unknown) { // File output try { ensureLogDir(); + rotateLogsIfNeeded(); appendFileSync(LOG_FILE, formatted + '\n'); } catch { // Silently fail file logging @@ -59,5 +97,7 @@ export const logger = { info: (msg: string, meta?: unknown) => log('info', msg, meta), warn: (msg: string, meta?: unknown) => log('warn', msg, meta), error: (msg: string, meta?: unknown) => log('error', msg, meta), - setLevel: (level: LogLevel) => { minLevel = level; }, + setLevel: (level: LogLevel) => { + minLevel = level; + }, }; diff --git a/src/utils/process.ts b/src/utils/process.ts index 66fa87b..14ffb4e 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -8,10 +8,25 @@ export interface SpawnResult { timedOut: boolean; } +export interface StreamSpawnOptions { + cwd: string; + timeout: number; + inactivityTimeout?: number; + onChunk: (chunk: string) => void | Promise; + minChunkSize?: number; +} + +export interface StreamSpawnResult { + stdout: string; + stderr: string; + exitCode: number; + timedOut: boolean; +} + export function spawnCLI( command: string, args: string[], - options: { cwd: string; timeout: number } + options: { cwd: string; timeout: number }, ): Promise { return new Promise((resolve) => { const proc = spawn(command, args, { @@ -20,7 +35,6 @@ export function spawnCLI( env: { ...process.env }, }); - // Close stdin immediately to prevent hanging proc.stdin.end(); let stdout = ''; @@ -65,3 +79,124 @@ export function spawnCLI( }); }); } + +export function spawnCLIStreaming( + command: string, + args: string[], + options: StreamSpawnOptions, +): Promise { + return new Promise((resolve) => { + logger.debug('Spawning streaming process', { + command, + args, + cwd: options.cwd, + timeout: options.timeout, + }); + + const proc = spawn(command, args, { + cwd: options.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + proc.stdin.end(); + + let stdout = ''; + let stderr = ''; + let timedOut = false; + let buffer = ''; + const minChunkSize = options.minChunkSize ?? 50; + const inactivityTimeout = options.inactivityTimeout ?? 300; + + const killProcess = (reason: string) => { + timedOut = true; + logger.warn(`Killing streaming process: ${reason}`, { + command, + stdoutLength: stdout.length, + }); + proc.kill('SIGTERM'); + setTimeout(() => { + if (!proc.killed) proc.kill('SIGKILL'); + }, 5000); + }; + + // Hard max timeout as safety net + const maxTimer = setTimeout(() => { + killProcess(`max timeout ${options.timeout}s exceeded`); + }, options.timeout * 1000); + + // Inactivity timeout: resets on every output + let inactivityTimer = setTimeout(() => { + killProcess(`no output for ${inactivityTimeout}s`); + }, inactivityTimeout * 1000); + + const resetInactivityTimer = () => { + clearTimeout(inactivityTimer); + inactivityTimer = setTimeout(() => { + killProcess(`no output for ${inactivityTimeout}s`); + }, inactivityTimeout * 1000); + }; + + const flushBuffer = async () => { + if (buffer.length >= minChunkSize) { + try { + await options.onChunk(buffer); + } catch (err) { + logger.warn('Error in onChunk callback', { error: (err as Error).message }); + } + buffer = ''; + } + }; + + proc.stdout.on('data', async (data: Buffer) => { + const chunk = data.toString(); + stdout += chunk; + buffer += chunk; + resetInactivityTimer(); + await flushBuffer(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + resetInactivityTimer(); + }); + + proc.on('close', async (code) => { + clearTimeout(maxTimer); + clearTimeout(inactivityTimer); + logger.debug('Streaming process closed', { + code, + stdoutLength: stdout.length, + stderrLength: stderr.length, + timedOut, + }); + + if (buffer.length > 0) { + try { + await options.onChunk(buffer); + } catch (err) { + logger.warn('Error in onChunk callback (final)', { error: (err as Error).message }); + } + } + + resolve({ + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: code ?? 1, + timedOut, + }); + }); + + proc.on('error', (err) => { + clearTimeout(maxTimer); + clearTimeout(inactivityTimer); + logger.error('Process spawn error (streaming)', { command, error: err.message }); + resolve({ + stdout: '', + stderr: err.message, + exitCode: 1, + timedOut: false, + }); + }); + }); +} diff --git a/tests/unit/adapters/claude.test.ts b/tests/unit/adapters/claude.test.ts index 6c72c38..070259d 100644 --- a/tests/unit/adapters/claude.test.ts +++ b/tests/unit/adapters/claude.test.ts @@ -12,16 +12,22 @@ vi.mock('../../../src/utils/logger.js', () => ({ vi.mock('../../../src/utils/process.js', () => ({ spawnCLI: vi.fn(), + spawnCLIStreaming: vi.fn(), })); -import { spawnCLI } from '../../../src/utils/process.js'; +import { spawnCLI, spawnCLIStreaming } from '../../../src/utils/process.js'; const mockedSpawnCLI = vi.mocked(spawnCLI); +const mockedSpawnCLIStreaming = vi.mocked(spawnCLIStreaming); function mockJsonResponse(result: string, sessionId: string) { return JSON.stringify({ result, session_id: sessionId, is_error: false }); } +function mockStreamJsonLines(lines: object[]): string { + return lines.map((l) => JSON.stringify(l)).join('\n'); +} + describe('ClaudeAdapter', () => { let adapter: ClaudeAdapter; @@ -80,7 +86,7 @@ describe('ClaudeAdapter', () => { expect(mockedSpawnCLI).toHaveBeenCalledWith( 'claude', expect.arrayContaining(['-p', 'hello', '--output-format', 'json', '--model', 'sonnet']), - expect.objectContaining({ cwd: '/tmp/project', timeout: 60 }) + expect.objectContaining({ cwd: '/tmp/project', timeout: 60 }), ); }); @@ -139,10 +145,12 @@ describe('ClaudeAdapter', () => { timedOut: true, }); - await expect(adapter.chat('hello', null, { - cwd: '/tmp/project', - timeout: 60, - })).rejects.toThrow(/Timeout/); + await expect( + adapter.chat('hello', null, { + cwd: '/tmp/project', + timeout: 60, + }), + ).rejects.toThrow(/Timeout/); }); it('should throw SESSION_EXPIRED on session errors', async () => { @@ -153,9 +161,11 @@ describe('ClaudeAdapter', () => { timedOut: false, }); - await expect(adapter.chat('hello', 'old-session', { - cwd: '/tmp/project', - })).rejects.toThrow('SESSION_EXPIRED'); + await expect( + adapter.chat('hello', 'old-session', { + cwd: '/tmp/project', + }), + ).rejects.toThrow('SESSION_EXPIRED'); }); it('should throw on non-zero exit code', async () => { @@ -166,9 +176,11 @@ describe('ClaudeAdapter', () => { timedOut: false, }); - await expect(adapter.chat('hello', null, { - cwd: '/tmp/project', - })).rejects.toThrow(/Erro ao processar/); + await expect( + adapter.chat('hello', null, { + cwd: '/tmp/project', + }), + ).rejects.toThrow(/Erro ao processar/); }); it('should fallback to raw stdout when JSON parse fails', async () => { @@ -201,4 +213,141 @@ describe('ClaudeAdapter', () => { expect(result.text).toBe('(resposta vazia)'); }); }); + + describe('chatStream', () => { + it('should exist as a method', () => { + expect(adapter.chatStream).toBeDefined(); + }); + + it('should use stream-json output format', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + await adapter.chatStream!('hello', null, { + cwd: '/tmp/project', + onChunk: () => {}, + }); + + const args = mockedSpawnCLIStreaming.mock.calls[0][1]; + expect(args).toContain('--output-format'); + expect(args).toContain('stream-json'); + }); + + it('should call onChunk with text deltas', async () => { + const streamOutput = mockStreamJsonLines([ + { type: 'assistant', message: 'Hello' }, + { type: 'assistant', message: 'Hello World' }, + { type: 'result', session_id: 'session-123', result: 'Hello World' }, + ]); + + mockedSpawnCLIStreaming.mockImplementation(async (_cmd, _args, options) => { + if (options.onChunk) { + await options.onChunk(streamOutput); + } + return { + stdout: streamOutput, + stderr: '', + exitCode: 0, + timedOut: false, + }; + }); + + const result = await adapter.chatStream!('test', null, { + cwd: '/tmp/project', + onChunk: () => {}, + }); + + expect(result.sessionId).toBe('session-123'); + }); + + it('should use default stream timeout of 600s', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + await adapter.chatStream!('hello', null, { + cwd: '/tmp/project', + onChunk: () => {}, + }); + + const options = mockedSpawnCLIStreaming.mock.calls[0][2]; + expect(options.timeout).toBe(600); + }); + + it('should throw on stream timeout', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 1, + timedOut: true, + }); + + await expect( + adapter.chatStream!('hello', null, { + cwd: '/tmp/project', + onChunk: () => {}, + timeout: 300, + }), + ).rejects.toThrow(/Timeout/); + }); + + it('should throw SESSION_EXPIRED on session errors', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: 'Session not found', + exitCode: 1, + timedOut: false, + }); + + await expect( + adapter.chatStream!('hello', 'old-session', { + cwd: '/tmp/project', + onChunk: () => {}, + }), + ).rejects.toThrow('SESSION_EXPIRED'); + }); + + it('should add --resume when sessionId is provided', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + await adapter.chatStream!('hello', 'session-456', { + cwd: '/tmp/project', + onChunk: () => {}, + }); + + const args = mockedSpawnCLIStreaming.mock.calls[0][1]; + expect(args).toContain('--resume'); + expect(args).toContain('session-456'); + }); + + it('should pass minChunkSize option', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + await adapter.chatStream!('hello', null, { + cwd: '/tmp/project', + onChunk: () => {}, + minChunkSize: 200, + }); + + const options = mockedSpawnCLIStreaming.mock.calls[0][2]; + expect(options.minChunkSize).toBe(200); + }); + }); }); diff --git a/tests/unit/adapters/gemini.test.ts b/tests/unit/adapters/gemini.test.ts index fb4219b..be07312 100644 --- a/tests/unit/adapters/gemini.test.ts +++ b/tests/unit/adapters/gemini.test.ts @@ -12,11 +12,13 @@ vi.mock('../../../src/utils/logger.js', () => ({ vi.mock('../../../src/utils/process.js', () => ({ spawnCLI: vi.fn(), + spawnCLIStreaming: vi.fn(), })); -import { spawnCLI } from '../../../src/utils/process.js'; +import { spawnCLI, spawnCLIStreaming } from '../../../src/utils/process.js'; const mockedSpawnCLI = vi.mocked(spawnCLI); +const mockedSpawnCLIStreaming = vi.mocked(spawnCLIStreaming); describe('GeminiAdapter', () => { let adapter: GeminiAdapter; @@ -76,7 +78,7 @@ describe('GeminiAdapter', () => { expect(mockedSpawnCLI).toHaveBeenCalledWith( 'gemini', expect.arrayContaining(['-p', 'hello', '--model', 'gemini-pro']), - expect.objectContaining({ cwd: '/tmp/project', timeout: 60 }) + expect.objectContaining({ cwd: '/tmp/project', timeout: 60 }), ); }); @@ -121,10 +123,12 @@ describe('GeminiAdapter', () => { timedOut: true, }); - await expect(adapter.chat('hello', null, { - cwd: '/tmp/project', - timeout: 30, - })).rejects.toThrow(/Timeout/); + await expect( + adapter.chat('hello', null, { + cwd: '/tmp/project', + timeout: 30, + }), + ).rejects.toThrow(/Timeout/); }); it('should throw SESSION_EXPIRED on session errors', async () => { @@ -135,9 +139,11 @@ describe('GeminiAdapter', () => { timedOut: false, }); - await expect(adapter.chat('hello', 'gemini:/tmp/project', { - cwd: '/tmp/project', - })).rejects.toThrow('SESSION_EXPIRED'); + await expect( + adapter.chat('hello', 'gemini:/tmp/project', { + cwd: '/tmp/project', + }), + ).rejects.toThrow('SESSION_EXPIRED'); }); it('should throw on non-zero exit code', async () => { @@ -148,9 +154,11 @@ describe('GeminiAdapter', () => { timedOut: false, }); - await expect(adapter.chat('hello', null, { - cwd: '/tmp/project', - })).rejects.toThrow(/Erro ao processar/); + await expect( + adapter.chat('hello', null, { + cwd: '/tmp/project', + }), + ).rejects.toThrow(/Erro ao processar/); }); it('should return "(resposta vazia)" on empty success', async () => { @@ -185,4 +193,142 @@ describe('GeminiAdapter', () => { expect(callArgs).not.toContain('--model'); }); }); + + describe('chatStream', () => { + it('should exist as a method', () => { + expect(adapter.chatStream).toBeDefined(); + }); + + it('should call spawnCLIStreaming with correct arguments', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: 'Streamed response', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await adapter.chatStream!('hello', null, { + cwd: '/tmp/project', + onChunk: () => {}, + }); + + expect(result.sessionId).toBe('gemini:/tmp/project'); + expect(mockedSpawnCLIStreaming).toHaveBeenCalledWith( + 'gemini', + expect.arrayContaining(['-p', 'hello']), + expect.objectContaining({ cwd: '/tmp/project' }), + ); + }); + + it('should use default stream timeout of 600s', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + await adapter.chatStream!('hello', null, { + cwd: '/tmp/project', + onChunk: () => {}, + }); + + const options = mockedSpawnCLIStreaming.mock.calls[0][2]; + expect(options.timeout).toBe(600); + }); + + it('should call onChunk with text deltas', async () => { + const chunks: string[] = []; + + mockedSpawnCLIStreaming.mockImplementation(async (_cmd, _args, options) => { + if (options.onChunk) { + await options.onChunk('Full response text'); + } + return { + stdout: 'Full response text', + stderr: '', + exitCode: 0, + timedOut: false, + }; + }); + + await adapter.chatStream!('test', null, { + cwd: '/tmp/project', + onChunk: (chunk) => { + chunks.push(chunk); + }, + }); + + expect(chunks.length).toBeGreaterThan(0); + }); + + it('should throw on stream timeout', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 1, + timedOut: true, + }); + + await expect( + adapter.chatStream!('hello', null, { + cwd: '/tmp/project', + onChunk: () => {}, + timeout: 300, + }), + ).rejects.toThrow(/Timeout/); + }); + + it('should throw SESSION_EXPIRED on session errors', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: 'could not resume session', + exitCode: 1, + timedOut: false, + }); + + await expect( + adapter.chatStream!('hello', 'gemini:/tmp/project', { + cwd: '/tmp/project', + onChunk: () => {}, + }), + ).rejects.toThrow('SESSION_EXPIRED'); + }); + + it('should add --resume latest when sessionId is provided', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + await adapter.chatStream!('hello', 'gemini:/tmp/project', { + cwd: '/tmp/project', + onChunk: () => {}, + }); + + const args = mockedSpawnCLIStreaming.mock.calls[0][1]; + expect(args).toContain('--resume'); + expect(args).toContain('latest'); + }); + + it('should pass minChunkSize option', async () => { + mockedSpawnCLIStreaming.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + await adapter.chatStream!('hello', null, { + cwd: '/tmp/project', + onChunk: () => {}, + minChunkSize: 200, + }); + + const options = mockedSpawnCLIStreaming.mock.calls[0][2]; + expect(options.minChunkSize).toBe(200); + }); + }); }); diff --git a/tests/unit/adapters/permissions.test.ts b/tests/unit/adapters/permissions.test.ts new file mode 100644 index 0000000..a7ee6f8 --- /dev/null +++ b/tests/unit/adapters/permissions.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { resolvePermissions } from '../../../src/adapters/permissions.js'; + +describe('resolvePermissions', () => { + describe('Claude adapter', () => { + it('should default to readonly', () => { + const result = resolvePermissions('claude'); + expect(result.allowedTools).toBe('Read,Glob,Grep'); + expect(result.skipPermissions).toBe(false); + }); + + it('should resolve readonly level', () => { + const result = resolvePermissions('claude', 'readonly'); + expect(result.allowedTools).toBe('Read,Glob,Grep'); + expect(result.skipPermissions).toBe(false); + }); + + it('should resolve read-write level', () => { + const result = resolvePermissions('claude', 'read-write'); + expect(result.allowedTools).toContain('Write'); + expect(result.allowedTools).toContain('Edit'); + expect(result.skipPermissions).toBe(true); + }); + + it('should resolve full level', () => { + const result = resolvePermissions('claude', 'full'); + expect(result.allowedTools).toContain('Bash'); + expect(result.allowedTools).toContain('Write'); + expect(result.allowedTools).toContain('Agent'); + expect(result.skipPermissions).toBe(true); + }); + + it('should allow allowedTools override', () => { + const result = resolvePermissions('claude', 'readonly', 'Read,Bash'); + expect(result.allowedTools).toBe('Read,Bash'); + }); + + it('should allow skipPermissions override', () => { + const result = resolvePermissions('claude', 'readonly', undefined, true); + expect(result.skipPermissions).toBe(true); + }); + }); + + describe('Gemini adapter', () => { + it('should default to readonly', () => { + const result = resolvePermissions('gemini'); + expect(result.allowedTools).toContain('ReadFileTool'); + expect(result.allowedTools).toContain('GlobTool'); + expect(result.skipPermissions).toBe(false); + }); + + it('should resolve read-write level with Gemini tool names', () => { + const result = resolvePermissions('gemini', 'read-write'); + expect(result.allowedTools).toContain('WriteFileTool'); + expect(result.allowedTools).toContain('EditTool'); + expect(result.skipPermissions).toBe(true); + }); + + it('should resolve full level with Gemini tool names', () => { + const result = resolvePermissions('gemini', 'full'); + expect(result.allowedTools).toContain('ShellTool'); + expect(result.allowedTools).toContain('WriteFileTool'); + expect(result.skipPermissions).toBe(true); + }); + }); + + describe('overrides take precedence', () => { + it('should use explicit allowedTools over permission level', () => { + const result = resolvePermissions('claude', 'full', 'Read,Glob'); + expect(result.allowedTools).toBe('Read,Glob'); + expect(result.skipPermissions).toBe(true); + }); + + it('should use explicit skipPermissions over permission level', () => { + const result = resolvePermissions('claude', 'full', undefined, false); + expect(result.skipPermissions).toBe(false); + }); + }); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 98de0c0..e5b2b7d 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -54,10 +54,12 @@ describe('loadConfig', () => { it('should exit if bot_token is missing', async () => { mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { allowed_users: ['123'] }, - projects: { test: { path: '/tmp', adapter: 'claude' } }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { allowed_users: ['123'] }, + projects: { test: { path: '/tmp', adapter: 'claude' } }, + }), + ); const loadConfig = await loadConfigFresh(); @@ -67,10 +69,12 @@ describe('loadConfig', () => { it('should exit if allowed_users is empty', async () => { mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { bot_token: 'test-token:ABC', allowed_users: [] }, - projects: { test: { path: '/tmp', adapter: 'claude' } }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { bot_token: 'test-token:ABC', allowed_users: [] }, + projects: { test: { path: '/tmp', adapter: 'claude' } }, + }), + ); const loadConfig = await loadConfigFresh(); @@ -80,9 +84,11 @@ describe('loadConfig', () => { it('should exit if no projects configured', async () => { mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, + }), + ); const loadConfig = await loadConfigFresh(); @@ -93,17 +99,24 @@ describe('loadConfig', () => { it('should load valid config with projects', async () => { // For project path validation, existsSync needs to return true mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, - projects: { - 'my-app': { path: '/tmp/my-app', adapter: 'claude', model: 'sonnet', description: 'Test' }, - }, - commands: { test: 'yarn test' }, - defaults: { - adapter: 'claude', - timeout: 60, - }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, + projects: { + 'my-app': { + path: '/tmp/my-app', + adapter: 'claude', + model: 'sonnet', + description: 'Test', + }, + }, + commands: { test: 'yarn test' }, + defaults: { + adapter: 'claude', + timeout: 60, + }, + }), + ); const loadConfig = await loadConfigFresh(); const config = loadConfig(); @@ -123,15 +136,17 @@ describe('loadConfig', () => { it('should handle v0.1 compat - migrate project (singular) to projects (plural)', async () => { mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, - project: { - name: 'legacy-app', - path: '/tmp/legacy', - adapter: 'claude', - model: 'opus', - }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, + project: { + name: 'legacy-app', + path: '/tmp/legacy', + adapter: 'claude', + model: 'opus', + }, + }), + ); const loadConfig = await loadConfigFresh(); const config = loadConfig(); @@ -143,15 +158,17 @@ describe('loadConfig', () => { it('should parse notifications config with defaults', async () => { mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, - projects: { app: { path: '/tmp/app', adapter: 'claude' } }, - notifications: { - enabled: true, - port: 8080, - secret: 'my-secret', - }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, + projects: { app: { path: '/tmp/app', adapter: 'claude' } }, + notifications: { + enabled: true, + port: 8080, + secret: 'my-secret', + }, + }), + ); const loadConfig = await loadConfigFresh(); const config = loadConfig(); @@ -160,7 +177,12 @@ describe('loadConfig', () => { expect(config.notifications!.enabled).toBe(true); expect(config.notifications!.port).toBe(8080); expect(config.notifications!.secret).toBe('my-secret'); - expect(config.notifications!.github_events).toEqual(['push', 'pull_request', 'issues', 'workflow_run']); + expect(config.notifications!.github_events).toEqual([ + 'push', + 'pull_request', + 'issues', + 'workflow_run', + ]); expect(config.notifications!.watched_branches).toEqual(['main', 'master', 'develop']); expect(config.notifications!.rate_limit.max_per_minute).toBe(30); expect(config.notifications!.rate_limit.cooldown_seconds).toBe(5); @@ -168,10 +190,12 @@ describe('loadConfig', () => { it('should have undefined notifications when not configured', async () => { mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, - projects: { app: { path: '/tmp/app', adapter: 'claude' } }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, + projects: { app: { path: '/tmp/app', adapter: 'claude' } }, + }), + ); const loadConfig = await loadConfigFresh(); const config = loadConfig(); @@ -181,10 +205,12 @@ describe('loadConfig', () => { it('should convert allowed_users to strings', async () => { mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { bot_token: 'test-token:ABC', allowed_users: [12345, 67890] }, - projects: { app: { path: '/tmp/app', adapter: 'claude' } }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { bot_token: 'test-token:ABC', allowed_users: [12345, 67890] }, + projects: { app: { path: '/tmp/app', adapter: 'claude' } }, + }), + ); const loadConfig = await loadConfigFresh(); const config = loadConfig(); @@ -194,16 +220,20 @@ describe('loadConfig', () => { it('should use default values when defaults not provided', async () => { mockedExistsSync.mockReturnValue(true); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, - projects: { app: { path: '/tmp/app', adapter: 'claude' } }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, + projects: { app: { path: '/tmp/app', adapter: 'claude' } }, + }), + ); const loadConfig = await loadConfigFresh(); const config = loadConfig(); expect(config.defaults.adapter).toBe('claude'); expect(config.defaults.timeout).toBe(120); + expect(config.defaults.stream_timeout).toBe(3600); + expect(config.defaults.inactivity_timeout).toBe(300); expect(config.defaults.max_message_length).toBe(4096); expect(config.defaults.session_ttl_hours).toBe(24); expect(config.defaults.command_timeout).toBe(60); @@ -216,10 +246,12 @@ describe('loadConfig', () => { if (p.includes('devbridge.config.json')) return true; return false; // project path does not exist }); - mockedReadFileSync.mockReturnValue(JSON.stringify({ - telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, - projects: { app: { path: '/nonexistent/path', adapter: 'claude' } }, - })); + mockedReadFileSync.mockReturnValue( + JSON.stringify({ + telegram: { bot_token: 'test-token:ABC', allowed_users: ['123'] }, + projects: { app: { path: '/nonexistent/path', adapter: 'claude' } }, + }), + ); const loadConfig = await loadConfigFresh(); diff --git a/tests/unit/utils/process.test.ts b/tests/unit/utils/process.test.ts index a60f9bd..7385cd9 100644 --- a/tests/unit/utils/process.test.ts +++ b/tests/unit/utils/process.test.ts @@ -9,7 +9,6 @@ vi.mock('../../../src/utils/logger.js', () => ({ }, })); -// We need to mock child_process.spawn for these tests const mockStdin = { end: vi.fn() }; const mockStdout = { on: vi.fn() }; const mockStderr = { on: vi.fn() }; @@ -27,14 +26,13 @@ vi.mock('node:child_process', () => ({ })), })); -import { spawnCLI } from '../../../src/utils/process.js'; +import { spawnCLI, spawnCLIStreaming } from '../../../src/utils/process.js'; import { spawn } from 'node:child_process'; describe('spawnCLI', () => { beforeEach(() => { vi.clearAllMocks(); - // Default: simulate immediate successful close mockOn.mockImplementation((event: string, cb: Function) => { if (event === 'close') { setTimeout(() => cb(0), 0); @@ -50,9 +48,13 @@ describe('spawnCLI', () => { timeout: 10, }); - expect(spawn).toHaveBeenCalledWith('claude', ['--version'], expect.objectContaining({ - cwd: '/tmp', - })); + expect(spawn).toHaveBeenCalledWith( + 'claude', + ['--version'], + expect.objectContaining({ + cwd: '/tmp', + }), + ); expect(result.exitCode).toBe(0); }); @@ -108,3 +110,132 @@ describe('spawnCLI', () => { expect(mockStdin.end).toHaveBeenCalled(); }); }); + +describe('spawnCLIStreaming', () => { + let chunksReceived: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + chunksReceived = []; + + mockOn.mockImplementation((event: string, cb: Function) => { + if (event === 'close') { + setTimeout(() => cb(0), 0); + } + }); + mockStdout.on.mockImplementation((_event: string, _cb: Function) => {}); + mockStderr.on.mockImplementation((_event: string, _cb: Function) => {}); + }); + + it('should call onChunk with data chunks', async () => { + const testData = 'Hello World'; + mockStdout.on.mockImplementation((event: string, cb: Function) => { + if (event === 'data') { + cb(Buffer.from(testData)); + } + }); + + const result = await spawnCLIStreaming('claude', ['-p', 'test'], { + cwd: '/tmp', + timeout: 10, + onChunk: (chunk) => { + chunksReceived.push(chunk); + }, + }); + + expect(result.stdout).toBe(testData); + expect(chunksReceived.length).toBeGreaterThan(0); + }); + + it('should respect minChunkSize', async () => { + mockStdout.on.mockImplementation((event: string, cb: Function) => { + if (event === 'data') { + cb(Buffer.from('short')); + } + }); + + await spawnCLIStreaming('claude', [], { + cwd: '/tmp', + timeout: 10, + minChunkSize: 100, + onChunk: (chunk) => { + chunksReceived.push(chunk); + }, + }); + + expect(chunksReceived.length).toBe(1); + expect(chunksReceived[0]).toBe('short'); + }); + + it('should flush buffer on close even if below minChunkSize', async () => { + mockStdout.on.mockImplementation((event: string, cb: Function) => { + if (event === 'data') { + cb(Buffer.from('tiny')); + } + }); + + await spawnCLIStreaming('claude', [], { + cwd: '/tmp', + timeout: 10, + minChunkSize: 1000, + onChunk: (chunk) => { + chunksReceived.push(chunk); + }, + }); + + expect(chunksReceived).toEqual(['tiny']); + }); + + it('should handle async onChunk callbacks', async () => { + const asyncChunks: string[] = []; + mockStdout.on.mockImplementation((event: string, cb: Function) => { + if (event === 'data') { + cb(Buffer.from('async test')); + } + }); + + await spawnCLIStreaming('claude', [], { + cwd: '/tmp', + timeout: 10, + onChunk: async (chunk) => { + await new Promise((r) => setTimeout(r, 10)); + asyncChunks.push(chunk); + }, + }); + + expect(asyncChunks.length).toBeGreaterThan(0); + }); + + it('should handle process errors gracefully', async () => { + mockOn.mockImplementation((event: string, cb: Function) => { + if (event === 'error') { + setTimeout(() => cb(new Error('spawn failed')), 0); + } + }); + + const result = await spawnCLIStreaming('nonexistent', [], { + cwd: '/tmp', + timeout: 10, + onChunk: () => {}, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('spawn failed'); + }); + + it('should capture stderr', async () => { + mockStderr.on.mockImplementation((event: string, cb: Function) => { + if (event === 'data') { + cb(Buffer.from('warning message')); + } + }); + + const result = await spawnCLIStreaming('claude', [], { + cwd: '/tmp', + timeout: 10, + onChunk: () => {}, + }); + + expect(result.stderr).toBe('warning message'); + }); +});