From 451e3a1d49321cb62ba0613a837c7f2c86c3f2cc Mon Sep 17 00:00:00 2001 From: Stanislav Tarasenko Date: Wed, 1 Apr 2026 02:19:29 +0200 Subject: [PATCH] feat(cli): Reimplement plugins --- CODEMIE_PLUGINS_SPEC.md | 531 ++++++++++++++++++ README.md | 78 +++ package-lock.json | 275 ++++++++- package.json | 4 + scripts/copy-plugins.js | 22 +- src/cli/commands/plugins-toolkit.ts | 350 ++++++++++++ src/cli/index.ts | 2 + src/toolkit/coding/coding.agent.ts | 149 +++++ src/toolkit/coding/coding.prompts.ts | 57 ++ src/toolkit/coding/coding.toolkit.ts | 24 + src/toolkit/coding/coding.tools.ts | 312 ++++++++++ src/toolkit/core/base-tool.ts | 89 +++ src/toolkit/core/base-toolkit.ts | 11 + src/toolkit/core/config.ts | 120 ++++ src/toolkit/core/nats-client.ts | 253 +++++++++ src/toolkit/core/plugin-client.ts | 50 ++ src/toolkit/core/types.ts | 56 ++ .../development/development.toolkit.ts | 48 ++ .../plugins/development/development.tools.ts | 302 ++++++++++ .../plugins/development/development.vars.ts | 57 ++ .../services/diff-update.service.ts | 202 +++++++ .../development/services/git.service.ts | 46 ++ .../plugins/logs-analysis/logs.toolkit.ts | 32 ++ .../plugins/logs-analysis/logs.tools.ts | 116 ++++ src/toolkit/plugins/mcp/mcp.adapter.ts | 50 ++ src/toolkit/plugins/mcp/mcp.toolkit.ts | 185 ++++++ src/toolkit/plugins/mcp/servers.json | 17 + .../notification/notification.toolkit.ts | 31 + .../notification/notification.tools.ts | 120 ++++ src/toolkit/proto/service.ts | 258 +++++++++ 30 files changed, 3827 insertions(+), 20 deletions(-) create mode 100644 CODEMIE_PLUGINS_SPEC.md create mode 100644 src/cli/commands/plugins-toolkit.ts create mode 100644 src/toolkit/coding/coding.agent.ts create mode 100644 src/toolkit/coding/coding.prompts.ts create mode 100644 src/toolkit/coding/coding.toolkit.ts create mode 100644 src/toolkit/coding/coding.tools.ts create mode 100644 src/toolkit/core/base-tool.ts create mode 100644 src/toolkit/core/base-toolkit.ts create mode 100644 src/toolkit/core/config.ts create mode 100644 src/toolkit/core/nats-client.ts create mode 100644 src/toolkit/core/plugin-client.ts create mode 100644 src/toolkit/core/types.ts create mode 100644 src/toolkit/plugins/development/development.toolkit.ts create mode 100644 src/toolkit/plugins/development/development.tools.ts create mode 100644 src/toolkit/plugins/development/development.vars.ts create mode 100644 src/toolkit/plugins/development/services/diff-update.service.ts create mode 100644 src/toolkit/plugins/development/services/git.service.ts create mode 100644 src/toolkit/plugins/logs-analysis/logs.toolkit.ts create mode 100644 src/toolkit/plugins/logs-analysis/logs.tools.ts create mode 100644 src/toolkit/plugins/mcp/mcp.adapter.ts create mode 100644 src/toolkit/plugins/mcp/mcp.toolkit.ts create mode 100644 src/toolkit/plugins/mcp/servers.json create mode 100644 src/toolkit/plugins/notification/notification.toolkit.ts create mode 100644 src/toolkit/plugins/notification/notification.tools.ts create mode 100644 src/toolkit/proto/service.ts diff --git a/CODEMIE_PLUGINS_SPEC.md b/CODEMIE_PLUGINS_SPEC.md new file mode 100644 index 00000000..6795c7f4 --- /dev/null +++ b/CODEMIE_PLUGINS_SPEC.md @@ -0,0 +1,531 @@ +# CODEMIE_PLUGINS_SPEC.md + +**Specification: Migrate `codemie-plugins` into `codemie` CLI** + +--- + +## Background + +This spec covers the full migration and rewrite of the `codemie-plugins` Python project into the `codemie-code` TypeScript/Node.js repository. The goal is a unified CLI where all plugin/toolkit functionality is accessible through a consistent `codemie` entry point. + +**Source project**: `codemie-plugins` (Python 3.12+, LangChain, NATS, Click) +**Target project**: `codemie-code` (TypeScript 5.3+, Node.js ≥20, Commander.js) + +**JIRA Acceptance Criteria:** +- [ ] All features and logic from `codemie-plugins` migrated into the CLI toolchain +- [ ] TypeScript as implementation language for core and plugin loading logic +- [ ] CLI exposes `codemie plugin ` syntax for plugin discovery, installation, and execution +- [ ] Existing CLI functionality preserved; no regressions +- [ ] Documentation for developing and using CLI plugins provided + +--- + +## Command Structure + +The existing `codemie plugin` (singular) manages user extensions (list/install/uninstall/enable/disable). The new toolkit functionality is added as a separate `codemie plugins` (plural) command group to avoid conflicts while matching the JIRA requirement for a `codemie plugin ` pattern. + +``` +codemie plugins # NEW command group (plural = toolkit runners) +├── development +│ └── run [--repo-path ] [--timeout ] [--write-mode diff|write] +├── mcp +│ ├── list +│ └── run -s [-e KEY=VAL] [--timeout ] +├── logs +│ └── run [--base-path ] [--timeout ] +├── notification +│ └── run [--timeout ] +├── code +│ [--model ] [--allowed-dir ]... [--mcp-servers ] [--debug] +└── config + ├── list + ├── get + ├── set + ├── generate-key + └── local-prompt [set |show] +``` + +The existing `codemie plugin` (singular) commands are unchanged: +``` +codemie plugin list | install | uninstall | enable | disable # unchanged +``` + +--- + +## Architecture Overview + +``` +src/ +├── toolkit/ # NEW subsystem +│ ├── core/ +│ │ ├── types.ts # RemoteTool, RemoteToolkit, PluginConfig +│ │ ├── base-tool.ts # Abstract BaseRemoteTool +│ │ ├── base-toolkit.ts # Abstract BaseRemoteToolkit +│ │ ├── nats-client.ts # NATS subscribe/publish via protobuf +│ │ ├── plugin-client.ts # Top-level orchestrator +│ │ └── config.ts # ToolkitConfigLoader (~/.codemie/toolkit.json) +│ ├── proto/ +│ │ ├── service.proto # Protocol definition (from codemie-plugins) +│ │ └── service.ts # TypeScript encode/decode helpers (protobufjs) +│ ├── plugins/ +│ │ ├── development/ +│ │ │ ├── development.toolkit.ts # FileSystemAndCommandToolkit +│ │ │ ├── development.tools.ts # 7 tools +│ │ │ ├── development.vars.ts # Metadata constants +│ │ │ └── services/ +│ │ │ ├── diff-update.service.ts # SEARCH/REPLACE engine +│ │ │ └── git.service.ts # Git via exec() +│ │ ├── mcp/ +│ │ │ ├── mcp.toolkit.ts +│ │ │ ├── mcp.adapter.ts +│ │ │ └── servers.json # Predefined: filesystem, puppeteer, jetbrains +│ │ ├── logs-analysis/ +│ │ │ ├── logs.toolkit.ts +│ │ │ └── logs.tools.ts # ParseLogFileTool +│ │ └── notification/ +│ │ ├── notification.toolkit.ts +│ │ └── notification.tools.ts # EmailTool (nodemailer) +│ └── coding/ +│ ├── coding.agent.ts # LangGraph ReAct agent +│ ├── coding.toolkit.ts # FilesystemToolkit (11 tools) +│ ├── coding.tools.ts # DynamicStructuredTool implementations +│ └── coding.prompts.ts # CODING_AGENT_PROMPT + .codemie/prompt.txt +└── cli/ + └── commands/ + └── plugins-toolkit.ts # createPluginsCommand() → 'codemie plugins' +``` + +**Layer flow** (follows existing 5-layer architecture): +``` +CLI (plugins-toolkit.ts) + └─> Toolkit Plugins (development, mcp, logs, notification, coding) + └─> Core (NatsClient, PluginClient, BaseRemoteTool) + └─> Utils (errors.ts, logger.ts, processes.ts, paths.ts) +``` + +--- + +## New Dependencies + +Add to `package.json`: + +| Package | Purpose | +|---|---| +| `nats ^2.x` | NATS.io TypeScript client (WebSocket + TCP) | +| `protobufjs ^7.x` | Runtime protobuf encode/decode for NATS messages | +| `nodemailer ^6.x` | SMTP email for notification toolkit | +| `@types/nodemailer ^6.x` | TypeScript types | + +Already available (no additions): `@langchain/core`, `@langchain/langgraph`, `@langchain/openai`, `zod ^4.1.12`, `fast-glob ^3.3.2`, `chalk`, `readline` (Node built-in). + +--- + +## Core Infrastructure + +### Types (`src/toolkit/core/types.ts`) + +```typescript +export interface RemoteToolMetadata { + name: string; + label?: string; + description?: string; + reactDescription?: string; + allowedPatterns?: [string, string][]; // [label, regex] — allow-list + deniedPatterns?: [string, string][]; // [label, regex] — block-list +} + +export interface RemoteTool { + metadata: RemoteToolMetadata; + getArgsSchema(): Record; // JSON Schema + execute(input: Record): Promise; +} + +export interface RemoteToolkit { + readonly label: string; + readonly description?: string; + getTools(): RemoteTool[]; +} + +export interface PluginConfig { + pluginKey: string; // PLUGIN_KEY env var — used as NATS bearer token + engineUri: string; // PLUGIN_ENGINE_URI — NATS server URL + pluginLabel?: string; // PLUGIN_LABEL env var + timeout?: number; // seconds, default 600 +} + +export interface ToolkitStoredConfig { + pluginKey?: string; + engineUri?: string; + smtpHost?: string; smtpPort?: number; + smtpUser?: string; smtpPassword?: string; +} +``` + +### BaseRemoteTool (`src/toolkit/core/base-tool.ts`) + +- `sanitizeInput(input)` — validates against `deniedPatterns`/`allowedPatterns` using regex, throws `PathSecurityError` (reuse from `src/utils/errors.ts`) +- `get prefixedName()` — returns `_${metadata.name}` (NATS subject convention) +- Path security: reuse `isPathWithinDirectory(workingDir, resolvedPath)` from `src/utils/paths.ts` + +### Protobuf (`src/toolkit/proto/service.ts`) + +Load `service.proto` at runtime via `protobufjs.load()`. Key message types: + +- `ServiceMeta { subject, handler: GET(0)|RUN(1), puppet: LANGCHAIN_TOOL(0) }` +- `LangChainTool { name?, description?, args_schema?, result?, error?, query? }` +- GET request → reply with tool schema (`name`, `description`, `args_schema` as JSON) +- RUN request → execute tool with `query` JSON input → reply with `result` or `error` + +### NatsClient (`src/toolkit/core/nats-client.ts`) + +Port of `codemie/nats_client.py` (experimental path only — legacy protocol skipped): + +```typescript +connect(): connects with { servers: engineUri, token: pluginKey } +// Subscribes to 3 patterns: +// {key}.list → handleList (GET — tool schema discovery) +// {key}.live.reply → handleLiveReply (heartbeat ACK) +// {key}.*.*.* → handleRun (RUN — tool execution) +// Publishes heartbeat to {key}.live every 60s +disconnect(): clearInterval(timer), nc.drain() +``` + +Subject format for tool invocations: `{plugin_key}.{session_id}.{label}.{_tool_name}` + +### PluginClient (`src/toolkit/core/plugin-client.ts`) + +Port of `codemie/client.py` (experimental path): +- Enriches tools: appends `(plugin: {label})` to descriptions +- Instantiates and manages `NatsClient` + +### Config (`src/toolkit/core/config.ts`) + +- Reads/writes `~/.codemie/toolkit.json` via `getCodemiePath('toolkit.json')` (reuse `src/utils/paths.ts`) +- Env var priority: `PLUGIN_KEY > PLUGIN_ENGINE_URI > PLUGIN_LABEL > toolkit.json > defaults` +- `generatePluginKey()` → `crypto.randomUUID()` (Node.js built-in) + +--- + +## Development Toolkit + +### Diff Update Service (`services/diff-update.service.ts`) + +Implements SEARCH/REPLACE block format used by AI coding assistants: + +``` +<<<<<<< SEARCH +content to find (must match exactly) +======= +replacement content +>>>>>>> REPLACE +``` + +- Parse blocks via regex: `/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g` +- Apply each block: exact match first → whitespace-normalized match fallback +- Throw `ToolExecutionError` if a SEARCH block is not found +- **Out of scope for v1**: Python's fuzzy similarity matching (`try_dotdotdots`) + +### Git Service (`services/git.service.ts`) + +```typescript +execute(command: string, args: string[]): Promise +// Allowlist: add, commit, push, pull, status, diff, log, checkout, branch, fetch, stash, merge +// Uses exec() from src/utils/processes.ts with cwd: repoPath +// Throws ToolExecutionError for unlisted commands +``` + +### Development Tools (7 tools) + +| Class | Input | Behavior | +|---|---|---| +| `ReadFileTool` | `{ file_path }` | Read with path validation | +| `WriteFileTool` | `{ file_path, content }` | Write + auto-mkdir | +| `ListDirectoryTool` | `{ directory }` | Files + types, max depth 2 | +| `RecursiveFileRetrievalTool` | `{ directory, pattern? }` | `fast-glob` recursive listing | +| `CommandLineTool` | `{ command, working_dir? }` | `exec()` with 30s timeout | +| `DiffUpdateFileTool` | `{ file_path, diff_content }` | Read → `DiffUpdateService` → write | +| `GenericGitTool` | `{ command, args? }` | Delegates to `GitService` | + +All tools validate paths via `isPathWithinDirectory(repoPath, resolvedPath)`. + +### FileSystemAndCommandToolkit + +```typescript +export class FileSystemAndCommandToolkit extends BaseRemoteToolkit { + readonly label = 'development'; + constructor(repoPath = process.cwd(), useDiffWrite = true) {} + // Returns 6-7 tools based on useDiffWrite flag +} +``` + +--- + +## MCP Adapter Toolkit + +**`servers.json`** (curated, not exact Python match): +- `filesystem` — `@modelcontextprotocol/server-filesystem` +- `puppeteer` — `@modelcontextprotocol/server-puppeteer` +- `jetbrains` — `@jetbrains/mcp-proxy` + +**`McpToolAdapter`** wraps `StructuredToolInterface` (LangChain) as `RemoteTool`. Reuses existing `@modelcontextprotocol/sdk` already in the project. + +**`McpAdapterToolkit`** — async `initialize()` must be called before `PluginClient.connect()`: + +```typescript +async initialize(): Promise // launches MCP servers, discovers tools, wraps as McpToolAdapter +``` + +--- + +## Logs Analysis Toolkit + +**`ParseLogFileTool`**: +- Input: `{ file_path, filter_pattern?, max_lines?, error_only? }` +- Reads file, applies optional regex filter or `/ERROR|WARN|EXCEPTION|FATAL/i` filter +- Returns last `max_lines` (default 1000) with line numbers + summary stats + +**`LogsAnalysisToolkit`** includes: `ReadFileTool`, `ListDirectoryTool`, `WriteFileTool`, `ParseLogFileTool`. + +--- + +## Notification Toolkit + +**`EmailTool`** (nodemailer): +- Input: `{ to, subject, body, cc?, attachment_path? }` +- SMTP config from env vars (`SMTP_HOST/PORT/USER/PASSWORD`) → `toolkit.json` → `ConfigurationError` + +--- + +## Interactive Coding Agent + +### Tools (11 `DynamicStructuredTool` with Zod schemas) + +| Tool | Zod Input | +|---|---| +| `read_file` | `{ path: z.string() }` | +| `read_multiple_files` | `{ paths: z.array(z.string()) }` | +| `write_file` | `{ path: z.string(), content: z.string() }` | +| `edit_file` | `{ path: z.string(), diff: z.string() }` (uses DiffUpdateService) | +| `create_directory` | `{ path: z.string() }` | +| `list_directory` | `{ path: z.string() }` | +| `directory_tree` | `{ path: z.string(), depth: z.number().optional() }` | +| `move_file` | `{ source: z.string(), destination: z.string() }` | +| `search_files` | `{ pattern: z.string(), path: z.string().optional() }` (fast-glob) | +| `list_allowed_directories` | `{}` | +| `execute_command` | `{ command: z.string(), cwd: z.string().optional() }` | + +### CodingAgent (`src/toolkit/coding/coding.agent.ts`) + +```typescript +export class CodingAgent { + constructor(private allowedDirs: string[], private mcpServers?: string[]) {} + + async initialize(): Promise { + // LLM priority: + // 1. env vars LLM_SERVICE_BASE_URL + LLM_SERVICE_API_KEY (direct Azure, matching Python) + // 2. active codemie profile via ConfigLoader.load() (SSO/LiteLLM/Bedrock proxy) + // All providers expose OpenAI-compatible API via proxy → use ChatOpenAI from @langchain/openai + + const profile = await ConfigLoader.load(process.cwd()); + const llm = new ChatOpenAI({ + configuration: { baseURL: process.env.LLM_SERVICE_BASE_URL ?? profile.baseUrl }, + openAIApiKey: process.env.LLM_SERVICE_API_KEY ?? profile.apiKey, + modelName: process.env.LLM_MODEL ?? profile.model, + streaming: true, + temperature: 0.2, + }); + + const tools = new CodingToolkit({ allowedDirs }).getTools(); + // + optional MCP tools via McpClientManager + this.agent = createReactAgent({ llm, tools }); + } + + async runInteractive(): Promise // readline REPL — reads stdin, streams output + async run(input: string): Promise // single-shot for scripting +} +``` + +### System Prompt + +- Default: `CODING_AGENT_PROMPT` constant in `coding.prompts.ts` +- Override: loads `.codemie/prompt.txt` from cwd if it exists (matches Python behavior) + +--- + +## CLI Commands (`src/cli/commands/plugins-toolkit.ts`) + +### Shared `runToolkitServer()` helper + +```typescript +async function runToolkitServer(toolkit: RemoteToolkit, timeout: number): Promise { + const config = ToolkitConfigLoader.buildPluginConfig(); + // throws ConfigurationError if PLUGIN_KEY not set + + const client = new PluginClient(toolkit.getTools(), config); + await client.connect(); + + const shutdown = async () => { await client.disconnect(); process.exit(0); }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + if (timeout > 0) setTimeout(shutdown, timeout * 1000); + + logger.success(`${toolkit.label} toolkit running. Press Ctrl+C to stop.`); + await new Promise(() => {}); // keep alive +} +``` + +### Register in `src/cli/index.ts` + +```typescript +import { createPluginsCommand } from './commands/plugins-toolkit.js'; +program.addCommand(createPluginsCommand()); // new — adds 'codemie plugins' +``` + +No changes to existing commands. The existing `createPluginCommand()` for `codemie plugin` (singular) remains unchanged. + +--- + +## Reused Existing Utilities + +| Utility | File | Used for | +|---|---|---| +| `isPathWithinDirectory()` | `src/utils/paths.ts` | Path security in all file tools | +| `getCodemiePath()` | `src/utils/paths.ts` | `~/.codemie/toolkit.json` location | +| `exec()` | `src/utils/processes.ts` | `CommandLineTool`, `GitService` | +| `ConfigurationError`, `PathSecurityError`, `ToolExecutionError` | `src/utils/errors.ts` | All error handling | +| `logger` | `src/utils/logger.ts` | All toolkit logging | +| `ConfigLoader.load()` | `src/utils/config.ts` | Active profile for coding agent LLM | +| `fast-glob` | package.json | `RecursiveFileRetrievalTool`, `SearchFilesTool` | +| `zod` | package.json | All Zod input schemas in coding tools | +| `@langchain/core`, `@langchain/langgraph`, `@langchain/openai` | package.json | Coding agent | +| `@modelcontextprotocol/sdk` | existing dep | MCP server lifecycle in mcp toolkit | + +--- + +## Implementation Sequence + +| Phase | Files | Notes | +|---|---|---| +| **1. Core infrastructure** | `types.ts`, `base-tool.ts`, `base-toolkit.ts`, `nats-client.ts`, `plugin-client.ts`, `config.ts`, `proto/service.ts` | Critical path | +| **2. Development toolkit** | `development.tools.ts`, `diff-update.service.ts`, `git.service.ts`, `development.toolkit.ts` | Highest value | +| **3. MCP adapter** | `mcp.adapter.ts`, `mcp.toolkit.ts`, `servers.json` | Reuses existing MCP infra | +| **4. Logs analysis** | `logs.tools.ts`, `logs.toolkit.ts` | Low complexity | +| **5. Notification** | `notification.tools.ts`, `notification.toolkit.ts` | Requires SMTP config | +| **6. Coding agent** | `coding.tools.ts`, `coding.toolkit.ts`, `coding.agent.ts`, `coding.prompts.ts` | No NATS dependency — fully independent | +| **7. CLI + integration** | `plugins-toolkit.ts`, patch `src/cli/index.ts` | Wire everything | + +**Total: ~30 new files, 4 new npm packages.** + +Phases 3–5 can proceed in parallel once Phase 1 is complete. Phase 6 is fully independent. + +--- + +## Documentation Requirements (JIRA AC) + +The following documentation must be created or updated as part of this ticket: + +### 1. Developer Guide: `docs/plugins/developing-plugins.md` + +- How to create a custom toolkit (implement `BaseRemoteToolkit` + `BaseRemoteTool`) +- Tool input/output schema conventions (Zod + JSON Schema) +- Path security requirements (allowed/denied patterns) +- How to register and run a custom toolkit via `PluginClient` + +### 2. User Guide: `docs/plugins/using-plugins.md` + +- PLUGIN_KEY setup and generation (`codemie plugins config generate-key`) +- Connecting to NATS: `PLUGIN_ENGINE_URI` configuration +- Running each toolkit: development, mcp, logs, notification +- Using the interactive coding agent (`codemie plugins code`) +- MCP server configuration + +### 3. CLI Help Text + +- All new commands must have complete `.description()` and `.helpText()` in Commander.js +- Options must have descriptions and defaults documented inline + +### 4. Update `README.md` + +- Add `codemie plugins` command group to the top-level command table +- Add quick-start section for toolkit usage + +--- + +## Verification + +### Build & Quality +```bash +npm install # installs new deps (nats, protobufjs, nodemailer) +npm run build # TypeScript compilation — must have 0 errors +npm run lint # ESLint — must have 0 warnings +``` + +### Development Toolkit (requires PLUGIN_KEY + NATS URI) +```bash +export PLUGIN_KEY=$(codemie plugins config generate-key) +export PLUGIN_ENGINE_URI=nats://nats-codemie.example.com:443 +codemie plugins development run --repo-path /tmp/test-repo +# Verify: agent on remote platform can invoke ReadFileTool, GenericGitTool, etc. +``` + +### MCP Toolkit +```bash +codemie plugins mcp list # shows available servers +codemie plugins mcp run -s filesystem # should connect to NATS +``` + +### Logs Analysis Toolkit +```bash +codemie plugins logs run --base-path /var/log +``` + +### Notification Toolkit +```bash +export SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=user SMTP_PASSWORD=pass +codemie plugins notification run +``` + +### Interactive Coding Agent (local, no NATS) +```bash +# With env vars (matching Python behavior): +export LLM_SERVICE_BASE_URL=https://ai.example.com/v1 +export LLM_SERVICE_API_KEY= +codemie plugins code --allowed-dir /tmp/project + +# With active codemie profile: +codemie plugins code --allowed-dir /tmp/project +``` + +### Config Management +```bash +codemie plugins config generate-key # generates UUID +codemie plugins config set engineUri nats://... +codemie plugins config list # shows all stored values +codemie plugins config local-prompt set "You are a TypeScript expert..." +``` + +### Regression Check +```bash +codemie plugin list # existing command — must still work +codemie plugin install # existing command — must still work +codemie list # agent list — must still work +codemie doctor # health check — must still pass +``` + +--- + +## Out of Scope (v1) + +- Legacy NATS protocol (Python's `LEGACY_PROTOCOL=true` path) +- Fuzzy/similarity matching in `DiffUpdateService` (Python's `try_dotdotdots` strategy) +- Bedrock direct auth in coding agent (proxied via SSO/LiteLLM, which is OpenAI-compatible) +- `deploy-templates/` (Helm chart) — not applicable for Node.js distribution + +--- + +## Security Considerations + +- `PLUGIN_KEY` is a sensitive token — stored in `~/.codemie/toolkit.json`, never logged +- Tool path validation: all file tools enforce `isPathWithinDirectory(repoPath, path)` from `src/utils/paths.ts` +- Shell execution in `CommandLineTool`: command string is passed to `exec()` with sanitized CWD; no shell interpolation +- SMTP credentials (`smtpPassword`) must never appear in logs — use `sanitizeLogArgs()` from `src/utils/security.ts` diff --git a/README.md b/README.md index db82945f..4c061644 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ CodeMie CLI is the all-in-one AI coding assistant for developers. - 🎯 **Profile Management** - Manage work, personal, and team configurations separately. - 📊 **Usage Analytics** - Track and analyze AI usage across all agents with detailed insights. - 🔧 **CI/CD Workflows** - Automated code review, fixes, and feature implementation. +- 🔌 **Toolkit Servers** - Expose local filesystem, git, MCP, logs, and email tools to a remote AI orchestrator via NATS. Includes a standalone LangGraph-based interactive coding agent. Perfect for developers seeking a powerful alternative to GitHub Copilot or Cursor. @@ -254,6 +255,82 @@ codemie opencode-metrics --discover --verbose Metrics are automatically extracted at session end and synced to the analytics system. Use `codemie analytics` to view comprehensive usage statistics across all agents. +### Toolkit Servers (`codemie plugins`) + +Expose local tools to a remote AI orchestrator via NATS, or run a standalone interactive coding agent — no NATS required. + +> **Note:** `codemie plugins` (plural) manages NATS toolkit servers. `codemie plugin` (singular) manages user extensions. + +**One-time setup:** +```bash +# Generate a plugin key and save it +codemie plugins config generate-key + +# Set the NATS server URL +codemie plugins config set engineUri nats://your-nats-server:4222 + +# View current config +codemie plugins config list +``` + +**Environment variables (override stored config):** + +| Variable | Purpose | +|---|---| +| `PLUGIN_KEY` | NATS bearer auth token (required for toolkit servers) | +| `PLUGIN_ENGINE_URI` | NATS server URL | +| `PLUGIN_LABEL` | Label appended to tool descriptions | + +**Filesystem & Git toolkit:** +```bash +codemie plugins development run --repo-path /path/to/repo +# Options: --write-mode diff|write --timeout +``` +Exposes: `read_file`, `list_directory`, `recursive_file_retrieval`, `diff_update_file` (SEARCH/REPLACE blocks), `execute_command`, `git` + +**MCP server adapter:** +```bash +# List built-in MCP servers +codemie plugins mcp list + +# Run one or more servers +codemie plugins mcp run -s filesystem +codemie plugins mcp run -s filesystem,puppeteer -e FILE_PATHS=/home/user +``` +Built-in servers: `filesystem`, `puppeteer`, `jetbrains` + +**Log analysis toolkit:** +```bash +codemie plugins logs run --base-path /var/log/myapp +``` +Exposes: `parse_log_file` (regex filter, error-only mode, stats), `read_file`, `list_directory` + +**Notification toolkit (email via SMTP):** +```bash +export SMTP_HOST=smtp.example.com SMTP_USER=user@example.com SMTP_PASSWORD=secret +codemie plugins notification run +``` +Or configure via `codemie plugins config set smtpHost ...` + +**Interactive coding agent (local, no NATS required):** +```bash +# Uses your active codemie profile for LLM access +codemie plugins code --allowed-dir /path/to/project + +# Explicit LLM settings +codemie plugins code --base-url https://api.openai.com/v1 --api-key sk-... --model gpt-4o + +# Or via env vars +LLM_SERVICE_BASE_URL= LLM_SERVICE_API_KEY= codemie plugins code +``` +Tools available to the agent: `read_file`, `write_file`, `edit_file`, `create_directory`, `list_directory`, `directory_tree`, `move_file`, `search_files`, `read_multiple_files`, `list_allowed_directories`, `execute_command` + +**Custom system prompt:** +```bash +codemie plugins config local-prompt set "You are a TypeScript expert..." +codemie plugins config local-prompt show +``` + ## Commands The CodeMie CLI has a rich set of commands for managing agents, configuration, and more. @@ -268,6 +345,7 @@ codemie profile # Manage provider profiles codemie analytics # View usage analytics (sessions, tokens, costs, tools) codemie workflow # Manage CI/CD workflows codemie doctor # Health check and diagnostics +codemie plugins # NATS toolkit servers and interactive coding agent ``` For a full command reference, see the [Commands Documentation](docs/COMMANDS.md). diff --git a/package-lock.json b/package-lock.json index 1e7e5e3c..f707e86f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "@langchain/core": "^1.0.4", "@langchain/langgraph": "^1.0.2", "@langchain/openai": "^1.1.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/nodemailer": "^7.0.11", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "codemie-sdk": "^0.1.330", @@ -33,6 +35,8 @@ "keytar": "^7.9.0", "mime-types": "^3.0.2", "minimatch": "^10.1.1", + "nats": "^2.29.3", + "nodemailer": "^8.0.4", "open": "^8.4.2", "ora": "^7.0.1", "strip-ansi": "^7.1.2", @@ -2192,6 +2196,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2411,6 +2427,68 @@ "@langchain/core": "^1.0.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3601,12 +3679,20 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -4122,6 +4208,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -4882,7 +5007,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5541,6 +5665,27 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -5561,18 +5706,19 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -5602,11 +5748,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5655,7 +5818,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, "funding": [ { "type": "github", @@ -6134,6 +6296,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6487,6 +6658,15 @@ "node": ">=8" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6639,7 +6819,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -6652,6 +6831,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -6702,6 +6890,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -7448,6 +7642,18 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/nats": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz", + "integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==", + "license": "Apache-2.0", + "dependencies": { + "nkeys.js": "1.1.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7464,6 +7670,18 @@ "node": ">= 0.6" } }, + "node_modules/nkeys.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz", + "integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==", + "license": "Apache-2.0", + "dependencies": { + "tweetnacl": "1.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/node-abi": { "version": "3.85.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", @@ -7482,6 +7700,15 @@ "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7788,7 +8015,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7852,6 +8078,15 @@ "node": ">=0.10" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/plimit-lit": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", @@ -8103,7 +8338,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8358,7 +8592,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8371,7 +8604,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9003,6 +9235,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9060,7 +9298,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -9333,7 +9570,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -9516,6 +9752,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "web/analytics": { "name": "@codemieai/analytics-web", "version": "0.0.11", diff --git a/package.json b/package.json index 6fc4175c..74da6be0 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,8 @@ "@langchain/core": "^1.0.4", "@langchain/langgraph": "^1.0.2", "@langchain/openai": "^1.1.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/nodemailer": "^7.0.11", "chalk": "^5.3.0", "cli-table3": "^0.6.5", "codemie-sdk": "^0.1.330", @@ -129,6 +131,8 @@ "keytar": "^7.9.0", "mime-types": "^3.0.2", "minimatch": "^10.1.1", + "nats": "^2.29.3", + "nodemailer": "^8.0.4", "open": "^8.4.2", "ora": "^7.0.1", "strip-ansi": "^7.1.2", diff --git a/scripts/copy-plugins.js b/scripts/copy-plugins.js index 0922f31e..7cfb830b 100644 --- a/scripts/copy-plugins.js +++ b/scripts/copy-plugins.js @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; -import { rmSync, mkdirSync, cpSync, existsSync } from 'fs'; +import { rmSync, mkdirSync, cpSync, existsSync, copyFileSync, statSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -23,6 +23,11 @@ const copyConfigs = [ name: 'Gemini extension', src: join(rootDir, 'src/agents/plugins/gemini/extension'), dest: join(rootDir, 'dist/agents/plugins/gemini/extension') + }, + { + name: 'MCP toolkit servers.json', + src: join(rootDir, 'src/toolkit/plugins/mcp/servers.json'), + dest: join(rootDir, 'dist/toolkit/plugins/mcp/servers.json') } ]; @@ -44,12 +49,19 @@ for (const config of copyConfigs) { } // Create parent directories - console.log(` - Creating ${config.dest}`); - mkdirSync(config.dest, { recursive: true }); + const srcStat2 = statSync(config.src); + const destDir = srcStat2.isDirectory() ? config.dest : dirname(config.dest); + console.log(` - Creating ${destDir}`); + mkdirSync(destDir, { recursive: true }); - // Copy recursively + // Copy recursively (directory) or as single file console.log(` - Copying from ${config.src}`); - cpSync(config.src, config.dest, { recursive: true }); + const srcStat = statSync(config.src); + if (srcStat.isDirectory()) { + cpSync(config.src, config.dest, { recursive: true }); + } else { + copyFileSync(config.src, config.dest); + } console.log(` ✓ ${config.name} copied successfully\n`); } diff --git a/src/cli/commands/plugins-toolkit.ts b/src/cli/commands/plugins-toolkit.ts new file mode 100644 index 00000000..bf7c7f15 --- /dev/null +++ b/src/cli/commands/plugins-toolkit.ts @@ -0,0 +1,350 @@ +/** + * `codemie plugins` command group — CodeMie Toolkit subsystem. + * + * Provides NATS-connected toolkit servers and an interactive coding agent. + * This is a SEPARATE command group from `codemie plugin` (singular), which + * manages user extensions (install/uninstall/enable/disable). + * + * Commands: + * codemie plugins development run + * codemie plugins mcp list | run + * codemie plugins logs run + * codemie plugins notification run + * codemie plugins code + * codemie plugins config list | get | set | generate-key | local-prompt + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { writeFileSync, mkdirSync, existsSync } from 'fs'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { logger } from '../../utils/logger.js'; +import { PluginClient } from '../../toolkit/core/plugin-client.js'; +import { ToolkitConfigLoader } from '../../toolkit/core/config.js'; +import { FileSystemAndCommandToolkit } from '../../toolkit/plugins/development/development.toolkit.js'; +import { McpAdapterToolkit } from '../../toolkit/plugins/mcp/mcp.toolkit.js'; +import { LogsAnalysisToolkit } from '../../toolkit/plugins/logs-analysis/logs.toolkit.js'; +import { NotificationToolkit } from '../../toolkit/plugins/notification/notification.toolkit.js'; +import { CodingAgent } from '../../toolkit/coding/coding.agent.js'; +import type { RemoteToolkit } from '../../toolkit/core/types.js'; + +// --------------------------------------------------------------------------- +// Shared toolkit server runner +// --------------------------------------------------------------------------- + +async function runToolkitServer(toolkit: RemoteToolkit, timeoutSec: number): Promise { + const config = ToolkitConfigLoader.buildPluginConfig(); + const client = new PluginClient(toolkit.getTools(), config); + await client.connect(); + + const shutdown = async (): Promise => { + console.log(chalk.dim('\nShutting down...')); + await client.disconnect(); + process.exit(0); + }; + + process.on('SIGINT', () => void shutdown()); + process.on('SIGTERM', () => void shutdown()); + + if (timeoutSec > 0) { + setTimeout(() => void shutdown(), timeoutSec * 1000); + } + + logger.success(`${toolkit.label} toolkit running. Press Ctrl+C to stop.`); + await new Promise(() => { /* keep alive */ }); +} + +// --------------------------------------------------------------------------- +// development +// --------------------------------------------------------------------------- + +function createDevelopmentCommand(): Command { + const cmd = new Command('development').description('Filesystem, git, and diff tools via NATS'); + + cmd + .command('run') + .description('Start the development toolkit and connect to NATS') + .option('--repo-path ', 'Repository root path', process.cwd()) + .option('--timeout ', 'Auto-disconnect after N seconds (0 = no timeout)', '0') + .option('--write-mode ', 'Write mode: "diff" (SEARCH/REPLACE) or "write" (overwrite)', 'diff') + .action(async (opts) => { + try { + const useDiffWrite = opts.writeMode !== 'write'; + const toolkit = new FileSystemAndCommandToolkit(opts.repoPath, useDiffWrite); + await runToolkitServer(toolkit, Number(opts.timeout)); + } catch (err) { + console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + }); + + return cmd; +} + +// --------------------------------------------------------------------------- +// mcp +// --------------------------------------------------------------------------- + +function createMcpCommand(): Command { + const cmd = new Command('mcp').description('MCP server adapter toolkit via NATS'); + + cmd + .command('list') + .description('List available MCP servers') + .action(() => { + const descriptions = McpAdapterToolkit.getServerDescriptions(); + if (Object.keys(descriptions).length === 0) { + console.log(chalk.yellow('No MCP servers configured.')); + return; + } + console.log(chalk.bold('\nAvailable MCP servers:\n')); + for (const [name, desc] of Object.entries(descriptions)) { + console.log(` ${chalk.cyan(name.padEnd(16))} ${chalk.dim(desc)}`); + } + console.log(''); + }); + + cmd + .command('run') + .description('Start MCP adapter toolkit and connect to NATS') + .requiredOption('-s, --servers ', 'Comma-separated list of MCP server names') + .option('-e, --env ', 'Environment variables as KEY=VAL pairs (comma-separated)') + .option('--timeout ', 'Auto-disconnect after N seconds (0 = no timeout)', '0') + .action(async (opts) => { + try { + const serverNames = String(opts.servers).split(',').map((s: string) => s.trim()); + const extraEnv: Record = {}; + + if (opts.env) { + for (const pair of String(opts.env).split(',')) { + const [key, ...rest] = pair.trim().split('='); + if (key && rest.length > 0) extraEnv[key] = rest.join('='); + } + } + + const toolkit = new McpAdapterToolkit(serverNames, extraEnv); + await toolkit.initialize(); + await runToolkitServer(toolkit, Number(opts.timeout)); + } catch (err) { + console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + }); + + return cmd; +} + +// --------------------------------------------------------------------------- +// logs +// --------------------------------------------------------------------------- + +function createLogsCommand(): Command { + const cmd = new Command('logs').description('Log file analysis toolkit via NATS'); + + cmd + .command('run') + .description('Start the logs analysis toolkit and connect to NATS') + .option('--base-path ', 'Base directory for log files', process.cwd()) + .option('--timeout ', 'Auto-disconnect after N seconds (0 = no timeout)', '0') + .action(async (opts) => { + try { + const toolkit = new LogsAnalysisToolkit(opts.basePath); + await runToolkitServer(toolkit, Number(opts.timeout)); + } catch (err) { + console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + }); + + return cmd; +} + +// --------------------------------------------------------------------------- +// notification +// --------------------------------------------------------------------------- + +function createNotificationCommand(): Command { + const cmd = new Command('notification').description('Email notification toolkit via NATS'); + + cmd + .command('run') + .description('Start the notification toolkit and connect to NATS') + .option('--timeout ', 'Auto-disconnect after N seconds (0 = no timeout)', '0') + .action(async (opts) => { + try { + const toolkit = new NotificationToolkit(); + await runToolkitServer(toolkit, Number(opts.timeout)); + } catch (err) { + console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + }); + + return cmd; +} + +// --------------------------------------------------------------------------- +// code (interactive coding agent) +// --------------------------------------------------------------------------- + +function createCodeCommand(): Command { + return new Command('code') + .description('Start an interactive coding agent (local, no NATS required)') + .option('--model ', 'LLM model name') + .option('--base-url ', 'LLM API base URL') + .option('--api-key ', 'LLM API key') + .option('--temperature ', 'Sampling temperature (0.0-1.0)', '0.2') + .option('--allowed-dir ', 'Allowed directory for file access (repeatable)', collectValues, [] as string[]) + .option('--mcp-servers ', 'Comma-separated MCP server names to include') + .option('--debug', 'Enable debug logging') + .action(async (opts) => { + try { + if (opts.debug) process.env['CODEMIE_DEBUG'] = 'true'; + + const allowedDirs: string[] = (opts.allowedDir as string[]).length > 0 + ? opts.allowedDir as string[] + : [process.cwd()]; + + const agent = new CodingAgent({ + allowedDirs, + model: opts.model as string | undefined, + baseUrl: opts.baseUrl as string | undefined, + apiKey: opts.apiKey as string | undefined, + temperature: Number(opts.temperature), + debug: opts.debug as boolean, + }); + + await agent.initialize(); + await agent.runInteractive(); + } catch (err) { + console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + }); +} + +// --------------------------------------------------------------------------- +// config +// --------------------------------------------------------------------------- + +function createConfigCommand(): Command { + const cmd = new Command('config').description('Manage toolkit configuration (~/.codemie/toolkit.json)'); + + cmd + .command('list') + .description('Show all toolkit configuration values') + .action(() => { + const display = ToolkitConfigLoader.getDisplayConfig(); + if (Object.keys(display).length === 0) { + console.log(chalk.yellow('\nNo toolkit configuration set.\n')); + console.log(chalk.dim('Run: codemie plugins config generate-key')); + return; + } + console.log(chalk.bold('\nToolkit configuration:\n')); + for (const [key, value] of Object.entries(display)) { + console.log(` ${chalk.cyan(key.padEnd(24))} ${value}`); + } + console.log(''); + }); + + cmd + .command('get ') + .description('Get a configuration value') + .action((key: string) => { + const config = ToolkitConfigLoader.load(); + const value = (config as Record)[key]; + if (value === undefined) { + console.log(chalk.yellow(`Key '${key}' not set`)); + } else { + console.log(String(value)); + } + }); + + cmd + .command('set ') + .description('Set a configuration value') + .action(async (key: string, value: string) => { + try { + const parsed = isNaN(Number(value)) ? value : Number(value); + await ToolkitConfigLoader.set(key as keyof import('../../toolkit/core/types.js').ToolkitStoredConfig, parsed); + console.log(chalk.green(`✓ Set ${key} = ${String(parsed)}`)); + } catch (err) { + console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + }); + + cmd + .command('generate-key') + .description('Generate a new plugin key (UUID)') + .action(async () => { + const key = ToolkitConfigLoader.generatePluginKey(); + console.log(chalk.bold('\nGenerated plugin key:')); + console.log(chalk.cyan(key)); + console.log(chalk.dim('\nSave it with:')); + console.log(chalk.dim(` codemie plugins config set pluginKey ${key}\n`)); + + // Optionally auto-save + await ToolkitConfigLoader.set('pluginKey', key); + console.log(chalk.green('✓ Saved to ~/.codemie/toolkit.json')); + }); + + cmd + .command('local-prompt') + .description('Manage the local coding agent system prompt (.codemie/prompt.txt)') + .addCommand( + new Command('show') + .description('Show the current local prompt (or default)') + .action(async () => { + const promptPath = join(process.cwd(), '.codemie', 'prompt.txt'); + if (existsSync(promptPath)) { + const content = await readFile(promptPath, 'utf-8'); + console.log(chalk.bold('\nLocal prompt (.codemie/prompt.txt):\n')); + console.log(content); + } else { + console.log(chalk.dim('No local prompt set. Using default CODING_AGENT_PROMPT.')); + } + }) + ) + .addCommand( + new Command('set') + .description('Set the local coding agent prompt') + .argument('', 'Prompt text to save') + .action(async (text: string) => { + const dir = join(process.cwd(), '.codemie'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const promptPath = join(dir, 'prompt.txt'); + writeFileSync(promptPath, text, 'utf-8'); + console.log(chalk.green(`✓ Prompt saved to ${promptPath}`)); + }) + ); + + return cmd; +} + +// --------------------------------------------------------------------------- +// Helper: collect repeatable option values +// --------------------------------------------------------------------------- + +function collectValues(value: string, prev: string[]): string[] { + return [...prev, value]; +} + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +export function createPluginsCommand(): Command { + const plugins = new Command('plugins') + .description('CodeMie toolkit: NATS-connected tool servers and interactive coding agent'); + + plugins.addCommand(createDevelopmentCommand()); + plugins.addCommand(createMcpCommand()); + plugins.addCommand(createLogsCommand()); + plugins.addCommand(createNotificationCommand()); + plugins.addCommand(createCodeCommand()); + plugins.addCommand(createConfigCommand()); + + return plugins; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index b9f3565f..eb744ee2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -27,6 +27,7 @@ import { createHookCommand } from './commands/hook.js'; import { createSoundCommand } from './commands/sound.js'; import { createSkillCommand } from './commands/skill.js'; import { createPluginCommand } from './commands/plugin.js'; +import { createPluginsCommand } from './commands/plugins-toolkit.js'; import { createOpencodeMetricsCommand } from './commands/opencode-metrics.js'; import { createTestMetricsCommand } from './commands/test-metrics.js'; import { createAssistantsCommand } from './commands/assistants/index.js'; @@ -70,6 +71,7 @@ program.addCommand(createHookCommand()); program.addCommand(createSoundCommand()); program.addCommand(createSkillCommand()); program.addCommand(createPluginCommand()); +program.addCommand(createPluginsCommand()); program.addCommand(createOpencodeMetricsCommand()); program.addCommand(createTestMetricsCommand()); diff --git a/src/toolkit/coding/coding.agent.ts b/src/toolkit/coding/coding.agent.ts new file mode 100644 index 00000000..83b6d9a6 --- /dev/null +++ b/src/toolkit/coding/coding.agent.ts @@ -0,0 +1,149 @@ +/** + * CodingAgent — interactive LangGraph ReAct agent for local filesystem coding tasks. + * + * Purely local — no NATS connection needed. + * + * LLM configuration priority: + * 1. env vars: LLM_SERVICE_BASE_URL + LLM_SERVICE_API_KEY + LLM_MODEL + * 2. active codemie profile (ConfigLoader.load) → baseUrl + apiKey + model + * (all providers expose OpenAI-compatible API via proxy) + */ + +import readline from 'readline'; +import chalk from 'chalk'; +import { createReactAgent } from '@langchain/langgraph/prebuilt'; +import { ChatOpenAI } from '@langchain/openai'; +import { HumanMessage, SystemMessage, BaseMessage } from '@langchain/core/messages'; +import { ConfigLoader } from '../../utils/config.js'; +import { logger } from '../../utils/logger.js'; +import { ConfigurationError } from '../../utils/errors.js'; +import { CodingToolkit } from './coding.toolkit.js'; +import { loadSystemPrompt } from './coding.prompts.js'; + +export interface CodingAgentOptions { + allowedDirs?: string[]; + model?: string; + baseUrl?: string; + apiKey?: string; + temperature?: number; + debug?: boolean; +} + +type ReactAgent = ReturnType; + +export class CodingAgent { + private agent!: ReactAgent; + private systemPrompt!: string; + private initialized = false; + + constructor(private readonly options: CodingAgentOptions = {}) {} + + async initialize(): Promise { + const llm = await this.buildLlm(); + const allowedDirs = this.options.allowedDirs?.length + ? this.options.allowedDirs + : [process.cwd()]; + + const tools = new CodingToolkit({ allowedDirs }).getTools(); + this.agent = createReactAgent({ llm, tools }); + this.systemPrompt = await loadSystemPrompt(process.cwd()); + this.initialized = true; + + logger.debug('CodingAgent: initialized', { + allowedDirs, + model: this.options.model ?? 'from config', + }); + } + + /** + * Interactive REPL — reads lines from stdin, streams agent output. + * Exits on Ctrl+C or empty input after newline. + */ + async runInteractive(): Promise { + if (!this.initialized) await this.initialize(); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, + }); + + console.log(chalk.bold.cyan('\nCodeMie Coding Agent')); + console.log(chalk.dim(`Allowed dirs: ${this.options.allowedDirs?.join(', ') ?? process.cwd()}`)); + console.log(chalk.dim('Type your request and press Enter. Press Ctrl+C to exit.\n')); + + for await (const line of rl) { + const input = line.trim(); + if (!input) continue; + + console.log(chalk.dim('\n─────────────────────────────────')); + try { + const response = await this.run(input); + console.log(chalk.white(response)); + } catch (err) { + console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`)); + } + console.log(chalk.dim('─────────────────────────────────\n')); + } + } + + /** + * Single-turn execution — returns the agent's final answer. + */ + async run(input: string): Promise { + if (!this.initialized) await this.initialize(); + + const messages: BaseMessage[] = [ + new SystemMessage(this.systemPrompt), + new HumanMessage(input), + ]; + + const result = await this.agent.invoke({ messages }); + const lastMessage = result.messages[result.messages.length - 1]; + return typeof lastMessage?.content === 'string' + ? lastMessage.content + : JSON.stringify(lastMessage?.content ?? ''); + } + + // --------------------------------------------------------------------------- + + private async buildLlm(): Promise { + // Priority 1: explicit options + let baseUrl = this.options.baseUrl; + let apiKey = this.options.apiKey; + let model = this.options.model; + + // Priority 2: env vars (matching Python behavior) + if (!baseUrl) baseUrl = process.env['LLM_SERVICE_BASE_URL']; + if (!apiKey) apiKey = process.env['LLM_SERVICE_API_KEY']; + if (!model) model = process.env['LLM_MODEL']; + + // Priority 3: active codemie profile + if (!baseUrl || !apiKey) { + try { + const profile = await ConfigLoader.load(process.cwd()); + if (!baseUrl) baseUrl = profile.baseUrl; + if (!apiKey) apiKey = profile.apiKey; + if (!model) model = profile.model; + logger.debug('CodingAgent: using active profile config'); + } catch { + // No profile configured + } + } + + if (!baseUrl || !apiKey) { + throw new ConfigurationError( + 'LLM configuration required. Set LLM_SERVICE_BASE_URL + LLM_SERVICE_API_KEY env vars, ' + + 'or configure a codemie profile with: codemie setup' + ); + } + + return new ChatOpenAI({ + configuration: { baseURL: baseUrl }, + openAIApiKey: apiKey, + modelName: model ?? 'anthropic.claude-3-7-sonnet-20250219-v1:0', + temperature: this.options.temperature ?? 0.2, + streaming: false, + }); + } +} diff --git a/src/toolkit/coding/coding.prompts.ts b/src/toolkit/coding/coding.prompts.ts new file mode 100644 index 00000000..827a4d4a --- /dev/null +++ b/src/toolkit/coding/coding.prompts.ts @@ -0,0 +1,57 @@ +/** + * System prompts for the interactive coding agent. + * + * Loads a custom prompt from .codemie/prompt.txt if it exists in the cwd, + * otherwise uses the default CODING_AGENT_PROMPT. + */ + +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { logger } from '../../utils/logger.js'; + +export const CODING_AGENT_PROMPT = `You are an expert software engineer and coding assistant with deep knowledge of software development, system design, and best practices. + +You have access to tools that allow you to: +- Read and write files in the allowed directories +- Search for files and content +- Execute shell commands +- Navigate directory structures + +Guidelines: +1. Always read a file before modifying it to understand its current content +2. Make targeted, minimal changes — avoid rewriting entire files when a small edit suffices +3. Explain what you are doing and why before taking actions +4. If you are unsure about something, read more context before proceeding +5. Use the search_files tool to find relevant files rather than guessing paths +6. Prefer editing existing files over creating new ones +7. When executing commands, explain what they do and check the output +8. Never hardcode secrets, credentials, or sensitive data into files + +When you encounter an error: +1. Read the full error message carefully +2. Look at the relevant code +3. Diagnose the root cause before attempting a fix +4. Test your fix by re-running the relevant code/tests + +Always strive for clean, readable, and maintainable code.`; + +/** + * Loads the system prompt. + * Priority: + * 1. .codemie/prompt.txt in the current working directory + * 2. Default CODING_AGENT_PROMPT + */ +export async function loadSystemPrompt(cwd: string = process.cwd()): Promise { + const localPromptPath = join(cwd, '.codemie', 'prompt.txt'); + if (existsSync(localPromptPath)) { + try { + const custom = await readFile(localPromptPath, 'utf-8'); + logger.debug(`CodingAgent: using custom prompt from ${localPromptPath}`); + return custom.trim(); + } catch { + logger.warn(`CodingAgent: failed to read custom prompt from ${localPromptPath}, using default`); + } + } + return CODING_AGENT_PROMPT; +} diff --git a/src/toolkit/coding/coding.toolkit.ts b/src/toolkit/coding/coding.toolkit.ts new file mode 100644 index 00000000..246fc162 --- /dev/null +++ b/src/toolkit/coding/coding.toolkit.ts @@ -0,0 +1,24 @@ +/** + * CodingToolkit — assembles the 11 filesystem/shell tools for the coding agent. + */ + +import type { DynamicStructuredTool } from '@langchain/core/tools'; +import { createCodingTools } from './coding.tools.js'; + +export interface CodingToolkitOptions { + allowedDirs: string[]; +} + +export class CodingToolkit { + private readonly allowedDirs: string[]; + + constructor(options: CodingToolkitOptions) { + this.allowedDirs = options.allowedDirs.length > 0 + ? options.allowedDirs + : [process.cwd()]; + } + + getTools(): DynamicStructuredTool[] { + return createCodingTools(this.allowedDirs); + } +} diff --git a/src/toolkit/coding/coding.tools.ts b/src/toolkit/coding/coding.tools.ts new file mode 100644 index 00000000..ae7a1182 --- /dev/null +++ b/src/toolkit/coding/coding.tools.ts @@ -0,0 +1,312 @@ +/** + * Coding agent tools — 11 filesystem + shell tools as DynamicStructuredTool + * (LangChain) with Zod schemas. + * + * These are compatible with createReactAgent from @langchain/langgraph. + */ + +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; +import { readFile, writeFile, mkdir, readdir, rename } from 'fs/promises'; +import { resolve, dirname, join } from 'path'; +import fg from 'fast-glob'; +import { exec } from '../../utils/exec.js'; +import { isPathWithinDirectory } from '../../utils/paths.js'; +import { PathSecurityError, ToolExecutionError } from '../../utils/errors.js'; +import { DiffUpdateService } from '../plugins/development/services/diff-update.service.js'; + +const diffService = new DiffUpdateService(); + +// --------------------------------------------------------------------------- +// Path validation helper +// --------------------------------------------------------------------------- + +function validatePath(allowedDirs: string[], inputPath: string): string { + const resolved = resolve(inputPath); + const allowed = allowedDirs.some(dir => isPathWithinDirectory(dir, resolved)); + if (!allowed) { + throw new PathSecurityError( + inputPath, + `path is outside the allowed directories: ${allowedDirs.join(', ')}` + ); + } + return resolved; +} + +// --------------------------------------------------------------------------- +// Tool factory +// --------------------------------------------------------------------------- + +export function createCodingTools(allowedDirs: string[]): DynamicStructuredTool[] { + return [ + + // 1. read_file + new DynamicStructuredTool({ + name: 'read_file', + description: 'Read the complete content of a file.', + schema: z.object({ + path: z.string().describe('File path to read'), + }), + func: async ({ path }) => { + const resolved = validatePath(allowedDirs, path); + try { + return await readFile(resolved, 'utf-8'); + } catch (err) { + throw new ToolExecutionError('read_file', (err as Error).message); + } + }, + }), + + // 2. read_multiple_files + new DynamicStructuredTool({ + name: 'read_multiple_files', + description: 'Read multiple files at once. Returns each file path followed by its content.', + schema: z.object({ + paths: z.array(z.string()).describe('Array of file paths to read'), + }), + func: async ({ paths }) => { + const results: string[] = []; + for (const path of paths) { + try { + const resolved = validatePath(allowedDirs, path); + const content = await readFile(resolved, 'utf-8'); + results.push(`=== ${path} ===\n${content}`); + } catch (err) { + results.push(`=== ${path} ===\nERROR: ${(err as Error).message}`); + } + } + return results.join('\n\n'); + }, + }), + + // 3. write_file + new DynamicStructuredTool({ + name: 'write_file', + description: 'Write content to a file, creating parent directories as needed.', + schema: z.object({ + path: z.string().describe('File path to write'), + content: z.string().describe('Content to write'), + }), + func: async ({ path, content }) => { + const resolved = validatePath(allowedDirs, path); + await mkdir(dirname(resolved), { recursive: true }); + await writeFile(resolved, content, 'utf-8'); + return `File written: ${path}`; + }, + }), + + // 4. edit_file + new DynamicStructuredTool({ + name: 'edit_file', + description: + 'Edit a file using SEARCH/REPLACE blocks. Format:\n' + + '<<<<<<< SEARCH\n\n=======\n\n>>>>>>> REPLACE', + schema: z.object({ + path: z.string().describe('File path to edit'), + diff: z.string().describe('SEARCH/REPLACE diff blocks'), + }), + func: async ({ path, diff }) => { + const resolved = validatePath(allowedDirs, path); + const original = await readFile(resolved, 'utf-8'); + const updated = diffService.applyDiff(original, diff); + await writeFile(resolved, updated, 'utf-8'); + return `File edited: ${path}`; + }, + }), + + // 5. create_directory + new DynamicStructuredTool({ + name: 'create_directory', + description: 'Create a directory (and any necessary parent directories).', + schema: z.object({ + path: z.string().describe('Directory path to create'), + }), + func: async ({ path }) => { + const resolved = validatePath(allowedDirs, path); + await mkdir(resolved, { recursive: true }); + return `Directory created: ${path}`; + }, + }), + + // 6. list_directory + new DynamicStructuredTool({ + name: 'list_directory', + description: 'List files and subdirectories in a directory.', + schema: z.object({ + path: z.string().describe('Directory path to list'), + }), + func: async ({ path }) => { + const resolved = validatePath(allowedDirs, path); + try { + const entries = await readdir(resolved, { withFileTypes: true }); + return entries + .map(e => `${e.isDirectory() ? '[DIR] ' : '[FILE]'} ${e.name}`) + .join('\n') || '(empty)'; + } catch (err) { + throw new ToolExecutionError('list_directory', (err as Error).message); + } + }, + }), + + // 7. directory_tree + new DynamicStructuredTool({ + name: 'directory_tree', + description: 'Show a tree view of a directory structure.', + schema: z.object({ + path: z.string().describe('Root directory path'), + depth: z.number().optional().describe('Maximum depth (default: 3)'), + }), + func: async ({ path, depth = 3 }) => { + const resolved = validatePath(allowedDirs, path); + return buildTree(resolved, '', depth, 0); + }, + }), + + // 8. move_file + new DynamicStructuredTool({ + name: 'move_file', + description: 'Move or rename a file or directory.', + schema: z.object({ + source: z.string().describe('Source path'), + destination: z.string().describe('Destination path'), + }), + func: async ({ source, destination }) => { + const resolvedSrc = validatePath(allowedDirs, source); + const resolvedDst = validatePath(allowedDirs, destination); + await mkdir(dirname(resolvedDst), { recursive: true }); + await rename(resolvedSrc, resolvedDst); + return `Moved: ${source} → ${destination}`; + }, + }), + + // 9. search_files + new DynamicStructuredTool({ + name: 'search_files', + description: 'Search for files matching a glob pattern or search for text content in files.', + schema: z.object({ + pattern: z.string().describe('Glob pattern (e.g. "**/*.ts") or text to search for'), + path: z.string().optional().describe('Base directory to search in (defaults to first allowed dir)'), + is_content_search: z.boolean().optional().describe('If true, search file contents for the pattern'), + }), + func: async ({ pattern, path, is_content_search }) => { + const basePath = path + ? validatePath(allowedDirs, path) + : (allowedDirs[0] ?? process.cwd()); + + if (is_content_search) { + // Content search using grep-like approach + const files = await fg('**/*', { + cwd: basePath, + onlyFiles: true, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'], + }); + + const matches: string[] = []; + const re = new RegExp(pattern, 'i'); + for (const file of files.slice(0, 500)) { + try { + const content = await readFile(join(basePath, file), 'utf-8'); + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (re.test(lines[i]!)) { + matches.push(`${file}:${i + 1}: ${lines[i]}`); + } + } + } catch { + // skip unreadable files + } + } + return matches.length > 0 ? matches.join('\n') : '(no matches)'; + } + + // File search by glob pattern + const files = await fg(pattern, { + cwd: basePath, + onlyFiles: false, + ignore: ['**/node_modules/**', '**/.git/**'], + }); + + return files.length > 0 ? files.join('\n') : '(no files found)'; + }, + }), + + // 10. list_allowed_directories + new DynamicStructuredTool({ + name: 'list_allowed_directories', + description: 'List the directories you are allowed to access.', + schema: z.object({}), + func: async () => { + return `Allowed directories:\n${allowedDirs.join('\n')}`; + }, + }), + + // 11. execute_command + new DynamicStructuredTool({ + name: 'execute_command', + description: 'Execute a shell command. Returns stdout and stderr.', + schema: z.object({ + command: z.string().describe('Shell command to execute'), + cwd: z.string().optional().describe('Working directory for the command'), + }), + func: async ({ command, cwd }) => { + const workingDir = cwd + ? validatePath(allowedDirs, cwd) + : (allowedDirs[0] ?? process.cwd()); + + const result = await exec('sh', ['-c', command], { + cwd: workingDir, + shell: false, + timeout: 60_000, + }); + + const output = [result.stdout, result.stderr].filter(Boolean).join('\n'); + return output + ? `Exit code ${result.code}:\n${output}` + : `Exit code ${result.code}: (no output)`; + }, + }), + + ]; +} + +// --------------------------------------------------------------------------- +// Tree builder helper +// --------------------------------------------------------------------------- + +async function buildTree( + dirPath: string, + prefix: string, + maxDepth: number, + currentDepth: number +): Promise { + if (currentDepth >= maxDepth) return `${prefix}...\n`; + + let result = ''; + try { + const entries = await readdir(dirPath, { withFileTypes: true }); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]!; + const isLast = i === entries.length - 1; + const connector = isLast ? '└── ' : '├── '; + const childPrefix = isLast ? `${prefix} ` : `${prefix}│ `; + + if (entry.name.startsWith('.') || ['node_modules', 'dist'].includes(entry.name)) { + continue; + } + + result += `${prefix}${connector}${entry.name}${entry.isDirectory() ? '/' : ''}\n`; + + if (entry.isDirectory()) { + result += await buildTree( + join(dirPath, entry.name), + childPrefix, + maxDepth, + currentDepth + 1 + ); + } + } + } catch { + // skip unreadable directories + } + return result; +} diff --git a/src/toolkit/core/base-tool.ts b/src/toolkit/core/base-tool.ts new file mode 100644 index 00000000..fa8d26ef --- /dev/null +++ b/src/toolkit/core/base-tool.ts @@ -0,0 +1,89 @@ +/** + * Abstract base class for all RemoteTool implementations. + * + * Handles: + * - Input sanitization via allowed/denied pattern validation + * - NATS subject name prefixing (tool name → _tool_name) + */ + +import { PathSecurityError, ToolExecutionError } from '../../utils/errors.js'; +import type { RemoteTool, RemoteToolMetadata } from './types.js'; + +export abstract class BaseRemoteTool implements RemoteTool { + abstract readonly metadata: RemoteToolMetadata; + abstract getArgsSchema(): Record; + abstract execute(input: Record): Promise; + + /** + * Tool name prefixed with `_` for NATS subject routing. + * Python convention: `read_file` → `_read_file` + */ + get prefixedName(): string { + return `_${this.metadata.name}`; + } + + /** + * Validates a string value against the tool's allowed/denied pattern lists. + * Throws PathSecurityError if a denied pattern matches or no allowed pattern matches. + * + * @param fieldName - Name of the field being validated (for error messages) + * @param value - String value to validate + */ + protected validatePatterns(fieldName: string, value: string): void { + const { deniedPatterns = [], allowedPatterns = [] } = this.metadata; + + for (const [label, pattern] of deniedPatterns) { + const re = new RegExp(pattern, 'i'); + if (re.test(value)) { + throw new PathSecurityError(value, `matches denied pattern [${label}]: ${pattern}`); + } + } + + if (allowedPatterns.length > 0) { + const matched = allowedPatterns.some(([, pattern]) => + new RegExp(pattern, 'i').test(value) + ); + if (!matched) { + throw new PathSecurityError( + value, + `does not match any allowed pattern for tool '${this.metadata.name}'` + ); + } + } + } + + /** + * Validates the full input object, extracting string values and checking patterns. + * Subclasses should call this at the start of execute() for string inputs. + */ + protected sanitizeInput(input: Record): void { + const hasPatterns = + (this.metadata.deniedPatterns?.length ?? 0) > 0 || + (this.metadata.allowedPatterns?.length ?? 0) > 0; + + if (!hasPatterns) return; + + for (const [key, value] of Object.entries(input)) { + if (typeof value === 'string') { + this.validatePatterns(key, value); + } + } + } + + /** + * Wraps execute() with error normalization — ensures errors always return string messages. + */ + async safeExecute(input: Record): Promise { + try { + return await this.execute(input); + } catch (error) { + if (error instanceof PathSecurityError || error instanceof ToolExecutionError) { + throw error; + } + throw new ToolExecutionError( + this.metadata.name, + error instanceof Error ? error.message : String(error) + ); + } + } +} diff --git a/src/toolkit/core/base-toolkit.ts b/src/toolkit/core/base-toolkit.ts new file mode 100644 index 00000000..1bcece94 --- /dev/null +++ b/src/toolkit/core/base-toolkit.ts @@ -0,0 +1,11 @@ +/** + * Abstract base class for all RemoteToolkit implementations. + */ + +import type { RemoteTool, RemoteToolkit } from './types.js'; + +export abstract class BaseRemoteToolkit implements RemoteToolkit { + abstract readonly label: string; + readonly description?: string; + abstract getTools(): RemoteTool[]; +} diff --git a/src/toolkit/core/config.ts b/src/toolkit/core/config.ts new file mode 100644 index 00000000..862edde2 --- /dev/null +++ b/src/toolkit/core/config.ts @@ -0,0 +1,120 @@ +/** + * ToolkitConfigLoader — manages ~/.codemie/toolkit.json configuration. + * + * Config priority (highest to lowest): + * 1. Environment variables (PLUGIN_KEY, PLUGIN_ENGINE_URI, PLUGIN_LABEL) + * 2. ~/.codemie/toolkit.json stored config + * 3. Defaults + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { mkdirSync } from 'fs'; +import { dirname } from 'path'; +import { randomUUID } from 'crypto'; +import { getCodemiePath } from '../../utils/paths.js'; +import { ConfigurationError } from '../../utils/errors.js'; +import { logger } from '../../utils/logger.js'; +import type { PluginConfig, ToolkitStoredConfig } from './types.js'; + +const CONFIG_FILENAME = 'toolkit.json'; + +export class ToolkitConfigLoader { + /** + * Reads ~/.codemie/toolkit.json. Returns empty object if file doesn't exist. + */ + static load(): ToolkitStoredConfig { + const configPath = getCodemiePath(CONFIG_FILENAME); + if (!existsSync(configPath)) { + return {}; + } + try { + const raw = readFileSync(configPath, 'utf-8'); + return JSON.parse(raw) as ToolkitStoredConfig; + } catch { + logger.warn(`ToolkitConfigLoader: failed to parse ${configPath}, using defaults`); + return {}; + } + } + + /** + * Writes the given config to ~/.codemie/toolkit.json. + */ + static async save(config: ToolkitStoredConfig): Promise { + const configPath = getCodemiePath(CONFIG_FILENAME); + const dir = dirname(configPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + logger.debug(`ToolkitConfigLoader: saved to ${configPath}`); + } + + /** + * Sets a single key in the stored config. + */ + static async set(key: keyof ToolkitStoredConfig, value: string | number): Promise { + const config = ToolkitConfigLoader.load(); + (config as Record)[key] = value; + await ToolkitConfigLoader.save(config); + } + + /** + * Builds a PluginConfig by merging env vars (priority) with stored config. + * Throws ConfigurationError if pluginKey is not available. + */ + static buildPluginConfig(): PluginConfig { + const stored = ToolkitConfigLoader.load(); + + const pluginKey = process.env['PLUGIN_KEY'] ?? stored.pluginKey; + if (!pluginKey) { + throw new ConfigurationError( + 'PLUGIN_KEY is required. Set it via the PLUGIN_KEY environment variable or run:\n' + + ' codemie plugins config generate-key\n' + + ' codemie plugins config set pluginKey ' + ); + } + + const engineUri = + process.env['PLUGIN_ENGINE_URI'] ?? + stored.engineUri ?? + 'nats://nats-codemie.epmd-edp-anthos.eu.gcp.cloudapp.epam.com:443'; + + const pluginLabel = process.env['PLUGIN_LABEL']; + + return { + pluginKey, + engineUri, + pluginLabel, + }; + } + + /** + * Generates a new unique plugin key (UUID v4). + */ + static generatePluginKey(): string { + return randomUUID(); + } + + /** + * Returns a display-safe version of the stored config (masks sensitive values). + */ + static getDisplayConfig(): Record { + const stored = ToolkitConfigLoader.load(); + const display: Record = {}; + + for (const [key, value] of Object.entries(stored)) { + if (value === undefined || value === null) continue; + const isSensitive = ['pluginKey', 'smtpPassword'].includes(key); + display[key] = isSensitive + ? `${String(value).slice(0, 4)}${'*'.repeat(8)}` + : String(value); + } + + // Also show env var overrides + if (process.env['PLUGIN_KEY']) display['PLUGIN_KEY (env)'] = '****'; + if (process.env['PLUGIN_ENGINE_URI']) display['PLUGIN_ENGINE_URI (env)'] = process.env['PLUGIN_ENGINE_URI']; + if (process.env['PLUGIN_LABEL']) display['PLUGIN_LABEL (env)'] = process.env['PLUGIN_LABEL']; + + return display; + } +} diff --git a/src/toolkit/core/nats-client.ts b/src/toolkit/core/nats-client.ts new file mode 100644 index 00000000..201d60c4 --- /dev/null +++ b/src/toolkit/core/nats-client.ts @@ -0,0 +1,253 @@ +/** + * NATS Client for the CodeMie Toolkit subsystem. + * + * Connects to a NATS broker using a PLUGIN_KEY bearer token, registers toolkit tools, + * and handles tool schema discovery (GET) and tool execution (RUN) via protobuf messages. + * + * NATS subject patterns: + * {plugin_key}.list → GET handler (tool schema discovery) + * {plugin_key}.live.reply → heartbeat ACK + * {plugin_key}.*.*.* → RUN handler (tool execution: key.sessionId.label.toolName) + * + * Sends live updates to {plugin_key}.live every 60 seconds. + */ + +import { + connect, + NatsConnection, + Subscription, + Msg, + StringCodec, +} from 'nats'; +import { logger } from '../../utils/logger.js'; +import { + decodeServiceRequest, + encodeServiceResponse, + Handler, + Puppet, + IServiceResponse, +} from '../proto/service.js'; +import type { RemoteTool, PluginConfig } from './types.js'; + +const HEARTBEAT_INTERVAL_MS = 60_000; + +export class NatsClient { + private nc!: NatsConnection; + private subscriptions: Subscription[] = []; + private heartbeatTimer?: ReturnType; + private sc = StringCodec(); + + constructor( + private readonly config: PluginConfig, + private readonly tools: RemoteTool[] + ) {} + + async connect(): Promise { + logger.debug('NatsClient: connecting', { uri: this.config.engineUri }); + + this.nc = await connect({ + servers: this.config.engineUri, + token: this.config.pluginKey, + }); + + const key = this.config.pluginKey; + + // Subscribe: tool schema discovery (GET) + const listSub = this.nc.subscribe(`${key}.list`); + this.subscriptions.push(listSub); + this.listenList(listSub); + + // Subscribe: heartbeat ACK + const liveSub = this.nc.subscribe(`${key}.live.reply`); + this.subscriptions.push(liveSub); + // live.reply is fire-and-forget — no handler needed + + // Subscribe: tool execution (wildcard: key.sessionId.label.toolName) + const runSub = this.nc.subscribe(`${key}.*.*.*`); + this.subscriptions.push(runSub); + this.listenRun(runSub); + + // Start heartbeat + this.heartbeatTimer = setInterval( + () => void this.sendLiveUpdate(), + HEARTBEAT_INTERVAL_MS + ); + + logger.debug('NatsClient: connected and subscribed'); + } + + async disconnect(): Promise { + clearInterval(this.heartbeatTimer); + for (const sub of this.subscriptions) { + sub.unsubscribe(); + } + if (this.nc) { + await this.nc.drain(); + } + logger.debug('NatsClient: disconnected'); + } + + /** + * Listens for GET requests on {key}.list — replies with tool schema. + */ + private async listenList(sub: Subscription): Promise { + for await (const msg of sub) { + void this.handleList(msg); + } + } + + /** + * Listens for RUN requests on {key}.*.*.* — executes tool and replies. + */ + private async listenRun(sub: Subscription): Promise { + for await (const msg of sub) { + void this.handleRun(msg); + } + } + + /** + * GET handler — tool schema discovery. + * Decodes request, finds matching tool by name from subject, replies with JSON schema. + */ + private async handleList(msg: Msg): Promise { + if (!msg.reply) return; + + try { + const request = await decodeServiceRequest(msg.data); + const toolName = this.extractToolNameFromSubject(request.meta?.subject ?? msg.subject); + const tool = this.findTool(toolName); + + if (!tool) { + logger.debug(`NatsClient: tool not found for subject '${msg.subject}'`); + return; + } + + const response: IServiceResponse = { + meta: { + subject: msg.subject, + handler: Handler.GET, + puppet: Puppet.LANGCHAIN_TOOL, + }, + puppet_response: { + lc_tool: { + name: `_${tool.metadata.name}`, + description: tool.metadata.description ?? '', + args_schema: JSON.stringify(tool.getArgsSchema()), + }, + }, + }; + + const encoded = await encodeServiceResponse(response); + msg.respond(encoded); + } catch (error) { + logger.error('NatsClient: error in handleList', error); + } + } + + /** + * RUN handler — tool execution. + * Decodes request, extracts query JSON, runs tool.execute(), replies with result. + */ + private async handleRun(msg: Msg): Promise { + if (!msg.reply) return; + + try { + const request = await decodeServiceRequest(msg.data); + const lcTool = request.puppet_request?.lc_tool; + + const toolName = this.extractToolNameFromSubject(msg.subject); + const tool = this.findTool(toolName); + + if (!tool) { + await this.respondError(msg, `Tool '${toolName}' not found`); + return; + } + + let input: Record = {}; + if (lcTool?.query) { + try { + input = JSON.parse(lcTool.query) as Record; + } catch { + await this.respondError(msg, `Invalid JSON in query: ${lcTool.query}`); + return; + } + } + + logger.debug(`NatsClient: executing tool '${tool.metadata.name}'`); + + const result = await tool.execute(input); + + const response: IServiceResponse = { + meta: { + subject: msg.subject, + handler: Handler.RUN, + puppet: Puppet.LANGCHAIN_TOOL, + }, + puppet_response: { + lc_tool: { result }, + }, + }; + + const encoded = await encodeServiceResponse(response); + msg.respond(encoded); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.respondError(msg, message); + logger.error('NatsClient: error in handleRun', error); + } + } + + /** + * Responds with an error ServiceResponse. + */ + private async respondError(msg: Msg, errorMessage: string): Promise { + if (!msg.reply) return; + try { + const response: IServiceResponse = { + meta: { subject: msg.subject, handler: Handler.RUN, puppet: Puppet.LANGCHAIN_TOOL }, + error: errorMessage, + }; + const encoded = await encodeServiceResponse(response); + msg.respond(encoded); + } catch { + // best effort + } + } + + /** + * Publishes a live update heartbeat to {plugin_key}.live. + * Includes a JSON list of registered tool subjects. + */ + private async sendLiveUpdate(): Promise { + try { + const key = this.config.pluginKey; + const toolSubjects = this.tools.map( + t => `${key}.*._default._${t.metadata.name}` + ); + this.nc.publish(`${key}.live`, this.sc.encode(JSON.stringify(toolSubjects))); + logger.debug('NatsClient: sent live update'); + } catch (error) { + logger.debug('NatsClient: failed to send live update', error); + } + } + + /** + * Extracts the tool name (last segment) from a NATS subject. + * Subject format: {key}.{sessionId}.{label}.{_toolName} + */ + private extractToolNameFromSubject(subject: string): string { + const parts = subject.split('.'); + return parts[parts.length - 1] ?? ''; + } + + /** + * Finds a tool by prefixed name (e.g. '_read_file') or bare name ('read_file'). + */ + private findTool(nameFromSubject: string): RemoteTool | undefined { + return this.tools.find( + t => + `_${t.metadata.name}` === nameFromSubject || + t.metadata.name === nameFromSubject + ); + } +} diff --git a/src/toolkit/core/plugin-client.ts b/src/toolkit/core/plugin-client.ts new file mode 100644 index 00000000..099c3bb1 --- /dev/null +++ b/src/toolkit/core/plugin-client.ts @@ -0,0 +1,50 @@ +/** + * PluginClient — top-level orchestrator for the CodeMie toolkit NATS integration. + * + * Enriches tools (appends plugin label to descriptions), then delegates to NatsClient + * for all NATS communication. + * + * Only implements the experimental protocol (NATS via NatsClient). + * The Python legacy protocol (per-tool ToolService) is not supported. + */ + +import { logger } from '../../utils/logger.js'; +import { NatsClient } from './nats-client.js'; +import type { RemoteTool, PluginConfig } from './types.js'; + +export class PluginClient { + private natsClient!: NatsClient; + + constructor( + private readonly tools: RemoteTool[], + private readonly config: PluginConfig + ) { + this.enrichTools(); + } + + async connect(): Promise { + this.natsClient = new NatsClient(this.config, this.tools); + await this.natsClient.connect(); + logger.success(`Connected to plugin engine: ${this.config.engineUri}`); + } + + async disconnect(): Promise { + await this.natsClient.disconnect(); + logger.info('Disconnected from plugin engine'); + } + + /** + * Enriches tool descriptions by appending the plugin label/key suffix. + * Mirrors Python client.py's `_enrich_tools_details()`. + */ + private enrichTools(): void { + if (!this.config.pluginLabel) return; + + for (const tool of this.tools) { + const current = tool.metadata.description ?? ''; + tool.metadata.description = current + ? `${current} (plugin: ${this.config.pluginLabel})` + : `(plugin: ${this.config.pluginLabel})`; + } + } +} diff --git a/src/toolkit/core/types.ts b/src/toolkit/core/types.ts new file mode 100644 index 00000000..d2203fe3 --- /dev/null +++ b/src/toolkit/core/types.ts @@ -0,0 +1,56 @@ +/** + * Core interfaces and types for the CodeMie Toolkit subsystem. + * + * Toolkits are collections of RemoteTool instances that can be connected to a + * NATS broker and exposed to a remote AI orchestrator (e.g. EPAM DIAL / CodeMie platform). + */ + +export interface RemoteToolMetadata { + /** Tool name — will be prefixed with `_` in NATS subjects */ + name: string; + /** Toolkit label (e.g. 'development', 'mcp') */ + label?: string; + /** Human-readable description shown to the remote agent */ + description?: string; + /** Extended description used in ReAct agent prompts */ + reactDescription?: string; + /** Allow-list: [label, regex] pairs — input must match ALL patterns */ + allowedPatterns?: [string, string][]; + /** Block-list: [label, regex] pairs — input must not match ANY pattern */ + deniedPatterns?: [string, string][]; +} + +export interface RemoteTool { + metadata: RemoteToolMetadata; + /** Returns JSON Schema for the tool's input parameters */ + getArgsSchema(): Record; + /** Executes the tool and returns a string result */ + execute(input: Record): Promise; +} + +export interface RemoteToolkit { + /** Unique label for this toolkit (used in NATS subjects) */ + readonly label: string; + readonly description?: string; + getTools(): RemoteTool[]; +} + +export interface PluginConfig { + /** PLUGIN_KEY env var — used as NATS bearer auth token */ + pluginKey: string; + /** PLUGIN_ENGINE_URI env var — NATS server URL */ + engineUri: string; + /** PLUGIN_LABEL env var — optional label appended to tool descriptions */ + pluginLabel?: string; + /** Auto-disconnect timeout in seconds (0 = no timeout) */ + timeout?: number; +} + +export interface ToolkitStoredConfig { + pluginKey?: string; + engineUri?: string; + smtpHost?: string; + smtpPort?: number; + smtpUser?: string; + smtpPassword?: string; +} diff --git a/src/toolkit/plugins/development/development.toolkit.ts b/src/toolkit/plugins/development/development.toolkit.ts new file mode 100644 index 00000000..f321dec5 --- /dev/null +++ b/src/toolkit/plugins/development/development.toolkit.ts @@ -0,0 +1,48 @@ +/** + * FileSystemAndCommandToolkit — development toolkit for repository operations. + * + * Tools: + * - ReadFile, ListDirectory, RecursiveFileRetrieval + * - DiffUpdateFile (default) or WriteFile (--write-mode write) + * - CommandLine (shell execution) + * - GenericGit (git operations) + */ + +import { BaseRemoteToolkit } from '../../core/base-toolkit.js'; +import type { RemoteTool } from '../../core/types.js'; +import { + ReadFileTool, + WriteFileTool, + ListDirectoryTool, + RecursiveFileRetrievalTool, + CommandLineTool, + DiffUpdateFileTool, + GenericGitTool, +} from './development.tools.js'; +import { GitService } from './services/git.service.js'; + +export class FileSystemAndCommandToolkit extends BaseRemoteToolkit { + readonly label = 'development'; + readonly description = 'Filesystem, shell command, and git operations for a code repository'; + + constructor( + private readonly repoPath: string = process.cwd(), + private readonly useDiffWrite: boolean = true + ) { + super(); + } + + getTools(): RemoteTool[] { + const gitService = new GitService(this.repoPath); + return [ + new ReadFileTool(this.repoPath), + new ListDirectoryTool(this.repoPath), + new RecursiveFileRetrievalTool(this.repoPath), + this.useDiffWrite + ? new DiffUpdateFileTool(this.repoPath) + : new WriteFileTool(this.repoPath), + new CommandLineTool(this.repoPath), + new GenericGitTool(gitService), + ]; + } +} diff --git a/src/toolkit/plugins/development/development.tools.ts b/src/toolkit/plugins/development/development.tools.ts new file mode 100644 index 00000000..f570fbeb --- /dev/null +++ b/src/toolkit/plugins/development/development.tools.ts @@ -0,0 +1,302 @@ +/** + * Development toolkit tools — filesystem + git + diff operations. + * + * All tools validate file paths against the configured repoPath using + * isPathWithinDirectory() from src/utils/paths.ts to prevent directory traversal. + */ + +import { readFile, writeFile, readdir, mkdir } from 'fs/promises'; +import { resolve, dirname } from 'path'; +import fg from 'fast-glob'; +import { BaseRemoteTool } from '../../core/base-tool.js'; +import { isPathWithinDirectory } from '../../../utils/paths.js'; +import { PathSecurityError, ToolExecutionError } from '../../../utils/errors.js'; +import { exec } from '../../../utils/exec.js'; +import type { RemoteToolMetadata } from '../../core/types.js'; +import { + READ_FILE_META, + WRITE_FILE_META, + LIST_DIRECTORY_META, + RECURSIVE_FILE_RETRIEVAL_META, + COMMAND_LINE_META, + DIFF_UPDATE_FILE_META, + GENERIC_GIT_META, +} from './development.vars.js'; +import { DiffUpdateService } from './services/diff-update.service.js'; +import { GitService } from './services/git.service.js'; + +// --------------------------------------------------------------------------- +// Shared path validation helper +// --------------------------------------------------------------------------- + +function validatePath(repoPath: string, inputPath: string): string { + const resolved = resolve(repoPath, inputPath); + if (!isPathWithinDirectory(repoPath, resolved)) { + throw new PathSecurityError( + inputPath, + `path is outside the allowed repository directory '${repoPath}'` + ); + } + return resolved; +} + +// --------------------------------------------------------------------------- +// ReadFileTool +// --------------------------------------------------------------------------- + +export class ReadFileTool extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata = READ_FILE_META; + + constructor(private readonly repoPath: string) { + super(); + } + + getArgsSchema(): Record { + return { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to the file (relative or absolute within repo)' }, + }, + required: ['file_path'], + }; + } + + async execute(input: Record): Promise { + const filePath = String(input['file_path'] ?? ''); + const resolved = validatePath(this.repoPath, filePath); + try { + const content = await readFile(resolved, 'utf-8'); + return content; + } catch (err) { + throw new ToolExecutionError('read_file', `Cannot read '${filePath}': ${(err as Error).message}`); + } + } +} + +// --------------------------------------------------------------------------- +// WriteFileTool +// --------------------------------------------------------------------------- + +export class WriteFileTool extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata = WRITE_FILE_META; + + constructor(private readonly repoPath: string) { + super(); + } + + getArgsSchema(): Record { + return { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to write (relative or absolute within repo)' }, + content: { type: 'string', description: 'File content to write' }, + }, + required: ['file_path', 'content'], + }; + } + + async execute(input: Record): Promise { + const filePath = String(input['file_path'] ?? ''); + const content = String(input['content'] ?? ''); + const resolved = validatePath(this.repoPath, filePath); + await mkdir(dirname(resolved), { recursive: true }); + await writeFile(resolved, content, 'utf-8'); + return `File written: ${filePath}`; + } +} + +// --------------------------------------------------------------------------- +// ListDirectoryTool +// --------------------------------------------------------------------------- + +export class ListDirectoryTool extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata = LIST_DIRECTORY_META; + + constructor(private readonly repoPath: string) { + super(); + } + + getArgsSchema(): Record { + return { + type: 'object', + properties: { + directory: { type: 'string', description: 'Directory path to list' }, + }, + required: ['directory'], + }; + } + + async execute(input: Record): Promise { + const directory = String(input['directory'] ?? '.'); + const resolved = validatePath(this.repoPath, directory); + + try { + const entries = await readdir(resolved, { withFileTypes: true }); + const lines = entries.map(e => `${e.isDirectory() ? 'd' : 'f'} ${e.name}`); + return lines.join('\n') || '(empty directory)'; + } catch (err) { + throw new ToolExecutionError('list_directory', `Cannot list '${directory}': ${(err as Error).message}`); + } + } +} + +// --------------------------------------------------------------------------- +// RecursiveFileRetrievalTool +// --------------------------------------------------------------------------- + +export class RecursiveFileRetrievalTool extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata = RECURSIVE_FILE_RETRIEVAL_META; + + constructor(private readonly repoPath: string) { + super(); + } + + getArgsSchema(): Record { + return { + type: 'object', + properties: { + directory: { type: 'string', description: 'Base directory to search in' }, + pattern: { type: 'string', description: 'Glob pattern to filter files (e.g. "**/*.ts")' }, + }, + required: ['directory'], + }; + } + + async execute(input: Record): Promise { + const directory = String(input['directory'] ?? '.'); + const pattern = input['pattern'] ? String(input['pattern']) : '**/*'; + const resolved = validatePath(this.repoPath, directory); + + const files = await fg(pattern, { + cwd: resolved, + onlyFiles: true, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'], + }); + + if (files.length === 0) return '(no files found)'; + return files.join('\n'); + } +} + +// --------------------------------------------------------------------------- +// CommandLineTool +// --------------------------------------------------------------------------- + +export class CommandLineTool extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata = COMMAND_LINE_META; + + constructor(private readonly repoPath: string) { + super(); + } + + getArgsSchema(): Record { + return { + type: 'object', + properties: { + command: { type: 'string', description: 'Shell command to execute' }, + working_dir: { type: 'string', description: 'Working directory (relative to repo root)' }, + }, + required: ['command'], + }; + } + + async execute(input: Record): Promise { + const command = String(input['command'] ?? ''); + const workingDirRel = input['working_dir'] ? String(input['working_dir']) : '.'; + const cwd = validatePath(this.repoPath, workingDirRel); + + const result = await exec('sh', ['-c', command], { + cwd, + shell: false, + timeout: 30_000, + }); + + const output = [result.stdout, result.stderr].filter(Boolean).join('\n'); + if (result.code !== 0) { + return `Exit code ${result.code}:\n${output}`; + } + return output || '(no output)'; + } +} + +// --------------------------------------------------------------------------- +// DiffUpdateFileTool +// --------------------------------------------------------------------------- + +const diffService = new DiffUpdateService(); + +export class DiffUpdateFileTool extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata = DIFF_UPDATE_FILE_META; + + constructor(private readonly repoPath: string) { + super(); + } + + getArgsSchema(): Record { + return { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to the file to update' }, + diff_content: { + type: 'string', + description: + 'SEARCH/REPLACE blocks. Format:\n' + + '<<<<<<< SEARCH\n\n=======\n\n>>>>>>> REPLACE', + }, + }, + required: ['file_path', 'diff_content'], + }; + } + + async execute(input: Record): Promise { + const filePath = String(input['file_path'] ?? ''); + const diffContent = String(input['diff_content'] ?? ''); + const resolved = validatePath(this.repoPath, filePath); + + let original = ''; + try { + original = await readFile(resolved, 'utf-8'); + } catch (err) { + throw new ToolExecutionError('diff_update_file', `Cannot read '${filePath}': ${(err as Error).message}`); + } + + const updated = diffService.applyDiff(original, diffContent); + await writeFile(resolved, updated, 'utf-8'); + return `File updated: ${filePath}`; + } +} + +// --------------------------------------------------------------------------- +// GenericGitTool +// --------------------------------------------------------------------------- + +export class GenericGitTool extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata = GENERIC_GIT_META; + + constructor(private readonly gitService: GitService) { + super(); + } + + getArgsSchema(): Record { + return { + type: 'object', + properties: { + command: { type: 'string', description: 'Git subcommand (e.g. "status", "add", "commit")' }, + args: { + type: 'array', + items: { type: 'string' }, + description: 'Additional arguments for the git command', + }, + }, + required: ['command'], + }; + } + + async execute(input: Record): Promise { + const command = String(input['command'] ?? ''); + const args = Array.isArray(input['args']) + ? (input['args'] as unknown[]).map(String) + : []; + return this.gitService.execute(command, args); + } +} diff --git a/src/toolkit/plugins/development/development.vars.ts b/src/toolkit/plugins/development/development.vars.ts new file mode 100644 index 00000000..8fe0e6d1 --- /dev/null +++ b/src/toolkit/plugins/development/development.vars.ts @@ -0,0 +1,57 @@ +/** + * RemoteToolMetadata constants for the development toolkit tools. + */ + +import type { RemoteToolMetadata } from '../../core/types.js'; + +export const READ_FILE_META: RemoteToolMetadata = { + name: 'read_file', + label: 'development', + description: 'Read the content of a file from the repository', + reactDescription: 'Use this to read the content of a file. Input: file_path (string).', +}; + +export const WRITE_FILE_META: RemoteToolMetadata = { + name: 'write_file', + label: 'development', + description: 'Write content to a file in the repository', + reactDescription: 'Use this to write or overwrite a file. Creates parent directories if needed. Input: file_path (string), content (string).', +}; + +export const LIST_DIRECTORY_META: RemoteToolMetadata = { + name: 'list_directory', + label: 'development', + description: 'List files and directories at a given path', + reactDescription: 'Use this to list the contents of a directory. Input: directory (string).', +}; + +export const RECURSIVE_FILE_RETRIEVAL_META: RemoteToolMetadata = { + name: 'recursive_file_retrieval', + label: 'development', + description: 'Recursively list all files matching an optional glob pattern', + reactDescription: 'Use this to recursively find files. Input: directory (string), pattern (optional glob string).', +}; + +export const COMMAND_LINE_META: RemoteToolMetadata = { + name: 'command_line', + label: 'development', + description: 'Execute a shell command in the repository directory', + reactDescription: 'Use this to run shell commands. Input: command (string), working_dir (optional string).', +}; + +export const DIFF_UPDATE_FILE_META: RemoteToolMetadata = { + name: 'diff_update_file', + label: 'development', + description: 'Apply SEARCH/REPLACE diff blocks to a file', + reactDescription: + 'Use this to edit a file using SEARCH/REPLACE blocks. ' + + 'Format: <<<<<<< SEARCH\\n\\n=======\\n\\n>>>>>>> REPLACE\\n' + + 'Input: file_path (string), diff_content (string).', +}; + +export const GENERIC_GIT_META: RemoteToolMetadata = { + name: 'generic_git', + label: 'development', + description: 'Execute a git command in the repository', + reactDescription: 'Use this to run git commands. Input: command (string, e.g. "status"), args (optional string[]).', +}; diff --git a/src/toolkit/plugins/development/services/diff-update.service.ts b/src/toolkit/plugins/development/services/diff-update.service.ts new file mode 100644 index 00000000..fcad08d7 --- /dev/null +++ b/src/toolkit/plugins/development/services/diff-update.service.ts @@ -0,0 +1,202 @@ +/** + * DiffUpdateService — applies SEARCH/REPLACE block diffs to file content. + * + * Block format (used by AI coding assistants like Aider, Cursor, etc.): + * + * <<<<<<< SEARCH + * content to find (must match exactly or with whitespace normalization) + * ======= + * replacement content + * >>>>>>> REPLACE + * + * Multiple blocks are applied sequentially. + * Throws ToolExecutionError if a SEARCH block cannot be found in the content. + */ + +import { ToolExecutionError } from '../../../../utils/errors.js'; + +const SEARCH_MARKER = '<<<<<<< SEARCH'; +const SEP_MARKER = '======='; +const REPLACE_MARKER = '>>>>>>> REPLACE'; + +export interface DiffBlock { + search: string; + replace: string; +} + +export class DiffUpdateService { + /** + * Parses diff content and applies all SEARCH/REPLACE blocks to fileContent. + * Returns the updated file content. + */ + applyDiff(fileContent: string, diffContent: string): string { + const blocks = this.parseBlocks(diffContent); + + if (blocks.length === 0) { + throw new ToolExecutionError('DiffUpdateService', 'No SEARCH/REPLACE blocks found in diff content'); + } + + let result = fileContent; + for (const block of blocks) { + result = this.applyBlock(result, block.search, block.replace); + } + return result; + } + + /** + * Parses SEARCH/REPLACE blocks from a diff string. + */ + parseBlocks(diffContent: string): DiffBlock[] { + const blocks: DiffBlock[] = []; + const lines = diffContent.split('\n'); + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + if (line === undefined || !line.trimEnd().endsWith(SEARCH_MARKER.trimStart())) { + // More flexible: check if line contains the SEARCH marker (handles leading whitespace) + if (line !== undefined && line.includes(SEARCH_MARKER)) { + // fall through to block parsing + } else { + i++; + continue; + } + } + + // Find the start of this block + if (line !== undefined && !line.includes(SEARCH_MARKER)) { + i++; + continue; + } + + // Collect SEARCH content + const searchLines: string[] = []; + i++; + while (i < lines.length && lines[i] !== undefined && !lines[i]!.includes(SEP_MARKER)) { + searchLines.push(lines[i]!); + i++; + } + + // Skip === separator + if (i >= lines.length || lines[i] === undefined || !lines[i]!.includes(SEP_MARKER)) { + break; + } + i++; + + // Collect REPLACE content + const replaceLines: string[] = []; + while (i < lines.length && lines[i] !== undefined && !lines[i]!.includes(REPLACE_MARKER)) { + replaceLines.push(lines[i]!); + i++; + } + + // Skip >>>>>>> REPLACE + i++; + + blocks.push({ + search: searchLines.join('\n'), + replace: replaceLines.join('\n'), + }); + } + + return blocks; + } + + /** + * Applies a single SEARCH/REPLACE block to content. + * Strategy: + * 1. Exact string match + * 2. Whitespace-normalized match (collapse runs of spaces/tabs) + */ + private applyBlock(content: string, search: string, replace: string): string { + // Strategy 1: exact match + if (content.includes(search)) { + return content.replace(search, replace); + } + + // Strategy 2: whitespace-normalized match + const normalizedContent = this.normalizeWhitespace(content); + const normalizedSearch = this.normalizeWhitespace(search); + + if (normalizedContent.includes(normalizedSearch)) { + // Re-apply using the normalized version — rebuild original with replacement + // Map normalized index back to original content using character-level scan + const originalIdx = this.findOriginalIndex(content, search); + if (originalIdx !== -1) { + const originalMatch = this.findOriginalMatch(content, search); + if (originalMatch !== null) { + return content.slice(0, originalMatch.start) + replace + content.slice(originalMatch.end); + } + } + // Fallback: apply on normalized then return (last resort) + return normalizedContent.replace(normalizedSearch, replace); + } + + throw new ToolExecutionError( + 'DiffUpdateService', + `SEARCH block not found in file content.\n` + + `Search pattern (first 200 chars):\n${search.slice(0, 200)}` + ); + } + + private normalizeWhitespace(text: string): string { + // Collapse multiple spaces/tabs to single space, normalize line endings + return text.replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' '); + } + + /** + * Finds the start index of a (whitespace-flexible) match in original content. + * Returns -1 if not found. + */ + private findOriginalIndex(content: string, search: string): number { + const searchLines = search.split('\n'); + const contentLines = content.split('\n'); + + for (let i = 0; i <= contentLines.length - searchLines.length; i++) { + let match = true; + for (let j = 0; j < searchLines.length; j++) { + const cl = contentLines[i + j]?.trim() ?? ''; + const sl = searchLines[j]?.trim() ?? ''; + if (cl !== sl) { + match = false; + break; + } + } + if (match) return i; + } + return -1; + } + + /** + * Returns { start, end } character offsets of the whitespace-flexible match. + */ + private findOriginalMatch( + content: string, + search: string + ): { start: number; end: number } | null { + const searchLines = search.split('\n'); + const contentLines = content.split('\n'); + + for (let i = 0; i <= contentLines.length - searchLines.length; i++) { + let match = true; + for (let j = 0; j < searchLines.length; j++) { + const cl = contentLines[i + j]?.trim() ?? ''; + const sl = searchLines[j]?.trim() ?? ''; + if (cl !== sl) { + match = false; + break; + } + } + if (match) { + // Compute character positions + const linesBefore = contentLines.slice(0, i).join('\n'); + const start = linesBefore.length + (i > 0 ? 1 : 0); + const matchedLines = contentLines.slice(i, i + searchLines.length).join('\n'); + const end = start + matchedLines.length; + return { start, end }; + } + } + return null; + } +} diff --git a/src/toolkit/plugins/development/services/git.service.ts b/src/toolkit/plugins/development/services/git.service.ts new file mode 100644 index 00000000..35138ab8 --- /dev/null +++ b/src/toolkit/plugins/development/services/git.service.ts @@ -0,0 +1,46 @@ +/** + * GitService — wraps git CLI operations via exec(). + * + * Only a curated allowlist of git commands is permitted to prevent + * arbitrary shell execution. + */ + +import { exec } from '../../../../utils/exec.js'; +import { ToolExecutionError } from '../../../../utils/errors.js'; + +const ALLOWED_GIT_COMMANDS = new Set([ + 'add', 'commit', 'push', 'pull', 'status', 'diff', + 'log', 'checkout', 'branch', 'fetch', 'stash', 'merge', + 'show', 'reset', 'rebase', 'tag', 'remote', +]); + +export class GitService { + constructor(private readonly repoPath: string) {} + + /** + * Executes a git command in the repository directory. + * @returns stdout of the git command + */ + async execute(command: string, args: string[] = []): Promise { + if (!ALLOWED_GIT_COMMANDS.has(command)) { + throw new ToolExecutionError( + 'git', + `Command '${command}' is not in the allowed list. Allowed: ${[...ALLOWED_GIT_COMMANDS].join(', ')}` + ); + } + + const result = await exec('git', [command, ...args], { + cwd: this.repoPath, + timeout: 60_000, + }); + + if (result.code !== 0) { + throw new ToolExecutionError( + 'git', + `'git ${command}' exited with code ${result.code}: ${result.stderr || result.stdout}` + ); + } + + return result.stdout; + } +} diff --git a/src/toolkit/plugins/logs-analysis/logs.toolkit.ts b/src/toolkit/plugins/logs-analysis/logs.toolkit.ts new file mode 100644 index 00000000..2359da0d --- /dev/null +++ b/src/toolkit/plugins/logs-analysis/logs.toolkit.ts @@ -0,0 +1,32 @@ +/** + * LogsAnalysisToolkit — toolkit for log file inspection and analysis. + * + * Tools: ReadFile, ListDirectory, WriteFile, ParseLogFile + */ + +import { BaseRemoteToolkit } from '../../core/base-toolkit.js'; +import type { RemoteTool } from '../../core/types.js'; +import { + ReadFileTool, + ListDirectoryTool, + WriteFileTool, + ParseLogFileTool, +} from './logs.tools.js'; + +export class LogsAnalysisToolkit extends BaseRemoteToolkit { + readonly label = 'logs'; + readonly description = 'Log file inspection and analysis toolkit'; + + constructor(private readonly basePath: string = process.cwd()) { + super(); + } + + getTools(): RemoteTool[] { + return [ + new ReadFileTool(this.basePath), + new ListDirectoryTool(this.basePath), + new WriteFileTool(this.basePath), + new ParseLogFileTool(this.basePath), + ]; + } +} diff --git a/src/toolkit/plugins/logs-analysis/logs.tools.ts b/src/toolkit/plugins/logs-analysis/logs.tools.ts new file mode 100644 index 00000000..49274b90 --- /dev/null +++ b/src/toolkit/plugins/logs-analysis/logs.tools.ts @@ -0,0 +1,116 @@ +/** + * Logs analysis toolkit tools. + */ + +import { readFile } from 'fs/promises'; +import { resolve } from 'path'; +import { BaseRemoteTool } from '../../core/base-tool.js'; +import { isPathWithinDirectory } from '../../../utils/paths.js'; +import { PathSecurityError, ToolExecutionError } from '../../../utils/errors.js'; +import { ReadFileTool, ListDirectoryTool, WriteFileTool } from '../development/development.tools.js'; +import type { RemoteToolMetadata } from '../../core/types.js'; + +// Re-export shared tools for use in the toolkit assembly +export { ReadFileTool, ListDirectoryTool, WriteFileTool }; + +// --------------------------------------------------------------------------- +// ParseLogFileTool +// --------------------------------------------------------------------------- + +const PARSE_LOG_META: RemoteToolMetadata = { + name: 'parse_log_file', + label: 'logs', + description: 'Parse and filter a log file, returning relevant lines with statistics', + reactDescription: + 'Use this to analyze log files. Supports regex filtering and error-only mode. ' + + 'Input: file_path (string), filter_pattern (optional regex), max_lines (optional number), error_only (optional boolean).', +}; + +export class ParseLogFileTool extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata = PARSE_LOG_META; + + constructor(private readonly basePath: string = process.cwd()) { + super(); + } + + getArgsSchema(): Record { + return { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to the log file' }, + filter_pattern: { + type: 'string', + description: 'Optional regex pattern to filter lines', + }, + max_lines: { + type: 'number', + description: 'Maximum number of lines to return (default: 1000)', + }, + error_only: { + type: 'boolean', + description: 'If true, only return lines matching ERROR/WARN/EXCEPTION/FATAL', + }, + }, + required: ['file_path'], + }; + } + + async execute(input: Record): Promise { + const filePath = String(input['file_path'] ?? ''); + const filterPattern = input['filter_pattern'] ? String(input['filter_pattern']) : null; + const maxLines = typeof input['max_lines'] === 'number' ? input['max_lines'] : 1000; + const errorOnly = input['error_only'] === true; + + const resolved = resolve(this.basePath, filePath); + if (!isPathWithinDirectory(this.basePath, resolved)) { + throw new PathSecurityError(filePath, `path is outside the allowed base directory '${this.basePath}'`); + } + + let content: string; + try { + content = await readFile(resolved, 'utf-8'); + } catch (err) { + throw new ToolExecutionError('parse_log_file', `Cannot read '${filePath}': ${(err as Error).message}`); + } + + let lines = content.split('\n'); + const totalLines = lines.length; + + // Apply error-only filter + if (errorOnly) { + const errorRe = /ERROR|WARN|EXCEPTION|FATAL/i; + lines = lines.filter(l => errorRe.test(l)); + } + + // Apply custom regex filter + if (filterPattern) { + try { + const re = new RegExp(filterPattern, 'i'); + lines = lines.filter(l => re.test(l)); + } catch { + throw new ToolExecutionError('parse_log_file', `Invalid filter_pattern regex: ${filterPattern}`); + } + } + + const filteredCount = lines.length; + + // Take last max_lines + if (lines.length > maxLines) { + lines = lines.slice(lines.length - maxLines); + } + + const numbered = lines.map((l, i) => `${i + 1}: ${l}`).join('\n'); + + const summary = [ + `--- Log Analysis Summary ---`, + `File: ${filePath}`, + `Total lines: ${totalLines}`, + `Matched lines: ${filteredCount}`, + `Showing: ${lines.length} lines`, + `---`, + numbered, + ].join('\n'); + + return summary; + } +} diff --git a/src/toolkit/plugins/mcp/mcp.adapter.ts b/src/toolkit/plugins/mcp/mcp.adapter.ts new file mode 100644 index 00000000..4a14e6f3 --- /dev/null +++ b/src/toolkit/plugins/mcp/mcp.adapter.ts @@ -0,0 +1,50 @@ +/** + * McpToolAdapter — wraps a tool from an MCP server as a RemoteTool. + * + * Bridges the MCP tool interface (listTools/callTool) with the RemoteTool + * interface used by the CodeMie NATS toolkit system. + */ + +import { BaseRemoteTool } from '../../core/base-tool.js'; +import { ToolExecutionError } from '../../../utils/errors.js'; +import type { RemoteToolMetadata } from '../../core/types.js'; +import type { Tool as McpToolDef } from '@modelcontextprotocol/sdk/types.js'; + +export interface McpToolCaller { + callTool(name: string, args: Record): Promise; +} + +export class McpToolAdapter extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata; + private readonly schema: Record; + + constructor( + private readonly mcpTool: McpToolDef, + private readonly serverName: string, + private readonly caller: McpToolCaller + ) { + super(); + this.metadata = { + name: mcpTool.name, + label: serverName, + description: mcpTool.description ?? `MCP tool: ${mcpTool.name} (server: ${serverName})`, + }; + // Store the JSON Schema from the MCP tool definition + this.schema = (mcpTool.inputSchema ?? { type: 'object', properties: {} }) as Record; + } + + getArgsSchema(): Record { + return this.schema; + } + + async execute(input: Record): Promise { + try { + return await this.caller.callTool(this.mcpTool.name, input); + } catch (err) { + throw new ToolExecutionError( + this.mcpTool.name, + err instanceof Error ? err.message : String(err) + ); + } + } +} diff --git a/src/toolkit/plugins/mcp/mcp.toolkit.ts b/src/toolkit/plugins/mcp/mcp.toolkit.ts new file mode 100644 index 00000000..3926f6cb --- /dev/null +++ b/src/toolkit/plugins/mcp/mcp.toolkit.ts @@ -0,0 +1,185 @@ +/** + * McpAdapterToolkit — bridges MCP servers to the NATS toolkit system. + * + * Launches the requested MCP servers as child processes (via stdio transport), + * discovers their tools via listTools(), and wraps each as a McpToolAdapter + * (RemoteTool). These are then exposed to the NATS broker via PluginClient. + * + * initialize() must be called before getTools() or connect(). + */ + +import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { getDirname } from '../../../utils/paths.js'; +import { logger } from '../../../utils/logger.js'; +import { ConfigurationError } from '../../../utils/errors.js'; +import { BaseRemoteToolkit } from '../../core/base-toolkit.js'; +import { McpToolAdapter, type McpToolCaller } from './mcp.adapter.js'; +import type { RemoteTool } from '../../core/types.js'; + +const __dirname = getDirname(import.meta.url); + +interface McpServerDefinition { + command: string; + args?: string[]; + env?: Record; + description?: string; +} + +class McpServerCaller implements McpToolCaller { + constructor(private readonly client: McpClient, private readonly serverName: string) {} + + async callTool(name: string, args: Record): Promise { + const result = await this.client.callTool({ name, arguments: args }); + // MCP callTool returns { content: Array<{ type, text? }> } + const content = result.content as Array<{ type: string; text?: string }>; + const text = content.map(c => c.text ?? '').join('\n'); + return text || JSON.stringify(result); + } +} + +export class McpAdapterToolkit extends BaseRemoteToolkit { + readonly label = 'mcp'; + readonly description = 'MCP server adapter — exposes MCP server tools via NATS'; + + private tools: RemoteTool[] = []; + private clients: McpClient[] = []; + + constructor( + private readonly serverNames: string[], + private readonly extraEnv?: Record + ) { + super(); + } + + /** + * Launches MCP servers and discovers their tools. + * Must be called before getTools() or PluginClient.connect(). + */ + async initialize(): Promise { + const serverDefs = this.loadServerDefinitions(); + + for (const serverName of this.serverNames) { + const def = serverDefs[serverName]; + if (!def) { + logger.warn(`McpAdapterToolkit: unknown server '${serverName}', skipping`); + continue; + } + + try { + const toolAdapters = await this.connectServer(serverName, def); + this.tools.push(...toolAdapters); + logger.debug(`McpAdapterToolkit: loaded ${toolAdapters.length} tools from '${serverName}'`); + } catch (err) { + logger.warn(`McpAdapterToolkit: failed to connect to '${serverName}': ${err instanceof Error ? err.message : String(err)}`); + } + } + + if (this.tools.length === 0) { + throw new ConfigurationError( + `No MCP tools discovered. Check server names: ${this.serverNames.join(', ')}` + ); + } + + logger.success(`McpAdapterToolkit: initialized with ${this.tools.length} tools from ${this.clients.length} server(s)`); + } + + getTools(): RemoteTool[] { + return this.tools; + } + + /** + * Disconnects all MCP server clients. + */ + async close(): Promise { + for (const client of this.clients) { + try { + await client.close(); + } catch { + // best effort + } + } + } + + private async connectServer( + serverName: string, + def: McpServerDefinition + ): Promise { + // Substitute ${FILE_PATHS} and ${DEFAULT_PATH} env var placeholders in args + const args = (def.args ?? []).map(arg => { + return arg + .replace('${FILE_PATHS}', process.env['FILE_PATHS'] ?? process.env['DEFAULT_PATH'] ?? process.cwd()) + .replace('${DEFAULT_PATH}', process.env['DEFAULT_PATH'] ?? process.cwd()); + }); + + const transport = new StdioClientTransport({ + command: def.command, + args, + env: { + ...process.env as Record, + ...def.env, + ...this.extraEnv, + }, + }); + + const client = new McpClient( + { name: 'codemie-toolkit', version: '1.0.0' }, + { capabilities: {} } + ); + + await client.connect(transport); + this.clients.push(client); + + const { tools } = await client.listTools(); + const caller = new McpServerCaller(client, serverName); + + return tools.map(tool => new McpToolAdapter(tool, serverName, caller)); + } + + /** + * Loads server definitions from the bundled servers.json. + */ + private loadServerDefinitions(): Record { + const serversPath = join(__dirname, 'servers.json'); + if (!existsSync(serversPath)) { + return {}; + } + try { + return JSON.parse(readFileSync(serversPath, 'utf-8')) as Record; + } catch { + return {}; + } + } + + /** + * Returns the list of available server names from servers.json. + */ + static getAvailableServers(): string[] { + try { + const serversPath = join(getDirname(import.meta.url), 'servers.json'); + if (!existsSync(serversPath)) return []; + const defs = JSON.parse(readFileSync(serversPath, 'utf-8')) as Record; + return Object.keys(defs); + } catch { + return []; + } + } + + /** + * Returns server definitions with descriptions for display. + */ + static getServerDescriptions(): Record { + try { + const serversPath = join(getDirname(import.meta.url), 'servers.json'); + if (!existsSync(serversPath)) return {}; + const defs = JSON.parse(readFileSync(serversPath, 'utf-8')) as Record; + return Object.fromEntries( + Object.entries(defs).map(([name, def]) => [name, def.description ?? name]) + ); + } catch { + return {}; + } + } +} diff --git a/src/toolkit/plugins/mcp/servers.json b/src/toolkit/plugins/mcp/servers.json new file mode 100644 index 00000000..0859544f --- /dev/null +++ b/src/toolkit/plugins/mcp/servers.json @@ -0,0 +1,17 @@ +{ + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "${FILE_PATHS}"], + "description": "MCP filesystem server — provides file read/write/list tools" + }, + "puppeteer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "description": "MCP Puppeteer server — provides browser automation tools" + }, + "jetbrains": { + "command": "npx", + "args": ["-y", "@jetbrains/mcp-proxy"], + "description": "JetBrains MCP proxy — exposes IDE tools from IntelliJ/WebStorm etc." + } +} diff --git a/src/toolkit/plugins/notification/notification.toolkit.ts b/src/toolkit/plugins/notification/notification.toolkit.ts new file mode 100644 index 00000000..314fad43 --- /dev/null +++ b/src/toolkit/plugins/notification/notification.toolkit.ts @@ -0,0 +1,31 @@ +/** + * NotificationToolkit — email notification via SMTP. + */ + +import { BaseRemoteToolkit } from '../../core/base-toolkit.js'; +import type { RemoteTool } from '../../core/types.js'; +import { EmailTool, buildSmtpConfig, type SmtpConfig } from './notification.tools.js'; +import { ToolkitConfigLoader } from '../../core/config.js'; + +export class NotificationToolkit extends BaseRemoteToolkit { + readonly label = 'notification'; + readonly description = 'Email notification toolkit via SMTP'; + + private readonly smtp: SmtpConfig; + + constructor(smtp?: SmtpConfig) { + super(); + // If smtp not explicitly provided, build from env vars + stored config + const stored = ToolkitConfigLoader.load(); + this.smtp = smtp ?? buildSmtpConfig({ + host: stored.smtpHost, + port: stored.smtpPort, + user: stored.smtpUser, + password: stored.smtpPassword, + }); + } + + getTools(): RemoteTool[] { + return [new EmailTool(this.smtp)]; + } +} diff --git a/src/toolkit/plugins/notification/notification.tools.ts b/src/toolkit/plugins/notification/notification.tools.ts new file mode 100644 index 00000000..c7c6b4d0 --- /dev/null +++ b/src/toolkit/plugins/notification/notification.tools.ts @@ -0,0 +1,120 @@ +/** + * Notification toolkit tools — email via SMTP (nodemailer). + */ + +import nodemailer from 'nodemailer'; +import { BaseRemoteTool } from '../../core/base-tool.js'; +import { ConfigurationError, ToolExecutionError } from '../../../utils/errors.js'; +import { logger } from '../../../utils/logger.js'; +import type { RemoteToolMetadata } from '../../core/types.js'; + +export interface SmtpConfig { + host: string; + port: number; + user: string; + password: string; + secure?: boolean; +} + +const EMAIL_META: RemoteToolMetadata = { + name: 'send_email', + label: 'notification', + description: 'Send an email via SMTP', + reactDescription: + 'Use this to send email notifications. ' + + 'Input: to (string), subject (string), body (string), cc (optional string), attachment_path (optional string).', +}; + +export class EmailTool extends BaseRemoteTool { + readonly metadata: RemoteToolMetadata = EMAIL_META; + + constructor(private readonly smtp: SmtpConfig) { + super(); + } + + getArgsSchema(): Record { + return { + type: 'object', + properties: { + to: { type: 'string', description: 'Recipient email address' }, + subject: { type: 'string', description: 'Email subject line' }, + body: { type: 'string', description: 'Email body (plain text)' }, + cc: { type: 'string', description: 'CC email address (optional)' }, + attachment_path: { type: 'string', description: 'Path to file to attach (optional)' }, + }, + required: ['to', 'subject', 'body'], + }; + } + + async execute(input: Record): Promise { + const to = String(input['to'] ?? ''); + const subject = String(input['subject'] ?? ''); + const body = String(input['body'] ?? ''); + const cc = input['cc'] ? String(input['cc']) : undefined; + const attachmentPath = input['attachment_path'] ? String(input['attachment_path']) : undefined; + + if (!to || !subject || !body) { + throw new ToolExecutionError('send_email', 'Fields to, subject, and body are required'); + } + + // Never log credentials + logger.debug('EmailTool: creating SMTP transporter', { host: this.smtp.host, port: this.smtp.port }); + + const transporter = nodemailer.createTransport({ + host: this.smtp.host, + port: this.smtp.port, + secure: this.smtp.secure ?? this.smtp.port === 465, + auth: { + user: this.smtp.user, + pass: this.smtp.password, + }, + }); + + const mailOptions: nodemailer.SendMailOptions = { + from: this.smtp.user, + to, + subject, + text: body, + }; + + if (cc) mailOptions.cc = cc; + if (attachmentPath) { + mailOptions.attachments = [{ path: attachmentPath }]; + } + + try { + const info = await transporter.sendMail(mailOptions); + logger.debug('EmailTool: email sent', { messageId: info.messageId }); + return `Email sent successfully to ${to} (messageId: ${info.messageId})`; + } catch (err) { + throw new ToolExecutionError('send_email', `SMTP error: ${(err as Error).message}`); + } + } +} + +/** + * Builds SmtpConfig from environment variables or throws ConfigurationError. + * Priority: env vars → provided defaults + */ +export function buildSmtpConfig(stored?: Partial): SmtpConfig { + const host = process.env['SMTP_HOST'] ?? stored?.host; + const port = process.env['SMTP_PORT'] ? Number(process.env['SMTP_PORT']) : stored?.port; + const user = process.env['SMTP_USER'] ?? stored?.user; + const password = process.env['SMTP_PASSWORD'] ?? stored?.password; + + if (!host || !port || !user || !password) { + const missing = [ + !host && 'SMTP_HOST', + !port && 'SMTP_PORT', + !user && 'SMTP_USER', + !password && 'SMTP_PASSWORD', + ].filter(Boolean).join(', '); + + throw new ConfigurationError( + `Missing SMTP configuration: ${missing}. ` + + 'Set via environment variables or: codemie plugins config set smtp ' + ); + } + + return { host, port, user, password }; +} diff --git a/src/toolkit/proto/service.ts b/src/toolkit/proto/service.ts new file mode 100644 index 00000000..8a9424f6 --- /dev/null +++ b/src/toolkit/proto/service.ts @@ -0,0 +1,258 @@ +/** + * Protobuf encode/decode for the CodeMie NATS plugin protocol — zero dependencies. + * + * Hand-written implementation of the service.proto schema using the protobuf + * binary wire format directly. No .proto file or protobufjs needed. + * + * Protobuf wire format cheat-sheet used here: + * - Wire type 0 (VARINT) : enums, int32 + * - Wire type 2 (LENGTH_DELIMITED) : strings, embedded messages + * - Field tag = (field_number << 3) | wire_type + * - Proto3: fields with default value (0 / "") are NOT encoded on the wire + * + * Schema (from proto/v1/service.proto): + * + * enum Handler { GET = 0; RUN = 1; } + * enum Puppet { LANGCHAIN_TOOL = 0; } + * + * LangChainTool { name?=1 description?=2 args_schema?=3 result?=4 error?=5 query?=6 } + * ServiceMeta { subject=1 handler=2 puppet=3 } + * PuppetRequest { lc_tool=1 } PuppetResponse { lc_tool=1 } + * ServiceRequest { meta=1 puppet_request=2 } + * ServiceResponse { meta=1 puppet_response=2 error=3 } + */ + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export const Handler = { GET: 0, RUN: 1 } as const; +export const Puppet = { LANGCHAIN_TOOL: 0 } as const; + +export interface ILangChainTool { + name?: string; + description?: string; + args_schema?: string; + result?: string; + error?: string; + query?: string; +} + +export interface IServiceMeta { + subject: string; + handler: number; + puppet: number; +} + +export interface IServiceRequest { + meta?: IServiceMeta; + puppet_request?: { lc_tool?: ILangChainTool }; +} + +export interface IServiceResponse { + meta?: IServiceMeta; + puppet_response?: { lc_tool?: ILangChainTool }; + error?: string; +} + +// --------------------------------------------------------------------------- +// Wire-format primitives +// --------------------------------------------------------------------------- + +const WIRE_VARINT = 0; +const WIRE_LEN = 2; +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function tag(field: number, wire: number): number { + return (field << 3) | wire; +} + +/** Encode a non-negative integer as a varint. */ +function varint(n: number): Uint8Array { + const out: number[] = []; + let v = n >>> 0; // treat as unsigned 32-bit + while (v > 0x7f) { + out.push((v & 0x7f) | 0x80); + v >>>= 7; + } + out.push(v); + return new Uint8Array(out); +} + +/** Read one varint from buf[offset], returns [value, bytesConsumed]. */ +function readVarint(buf: Uint8Array, offset: number): [number, number] { + let value = 0, shift = 0, i = offset; + while (i < buf.length) { + const b = buf[i++]!; + value |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + return [value >>> 0, i - offset]; +} + +/** Concatenate multiple Uint8Array segments. */ +function cat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((s, p) => s + p.length, 0); + const out = new Uint8Array(total); + let pos = 0; + for (const p of parts) { out.set(p, pos); pos += p.length; } + return out; +} + +// --------------------------------------------------------------------------- +// Field encoders +// --------------------------------------------------------------------------- + +/** Encode a string field (wire type 2). Skipped if value is empty. */ +function strField(fieldNum: number, value: string | undefined): Uint8Array { + if (!value) return new Uint8Array(0); + const bytes = enc.encode(value); + return cat(varint(tag(fieldNum, WIRE_LEN)), varint(bytes.length), bytes); +} + +/** Encode an enum/int32 field (wire type 0). Skipped if value is 0 (proto3 default). */ +function intField(fieldNum: number, value: number): Uint8Array { + if (value === 0) return new Uint8Array(0); + return cat(varint(tag(fieldNum, WIRE_VARINT)), varint(value)); +} + +/** Encode an embedded message field (wire type 2). Skipped if empty. */ +function msgField(fieldNum: number, bytes: Uint8Array): Uint8Array { + if (bytes.length === 0) return new Uint8Array(0); + return cat(varint(tag(fieldNum, WIRE_LEN)), varint(bytes.length), bytes); +} + +// --------------------------------------------------------------------------- +// Message encoders +// --------------------------------------------------------------------------- + +function encodeLangChainTool(t: ILangChainTool): Uint8Array { + return cat( + strField(1, t.name), + strField(2, t.description), + strField(3, t.args_schema), + strField(4, t.result), + strField(5, t.error), + strField(6, t.query), + ); +} + +function encodeServiceMeta(m: IServiceMeta): Uint8Array { + return cat( + strField(1, m.subject), + intField(2, m.handler), + intField(3, m.puppet), + ); +} + +function encodeServiceResponse(r: IServiceResponse): Uint8Array { + const metaBytes = r.meta ? encodeServiceMeta(r.meta) : new Uint8Array(0); + const respBytes = r.puppet_response?.lc_tool + ? msgField(1, encodeLangChainTool(r.puppet_response.lc_tool)) + : new Uint8Array(0); + + return cat( + msgField(1, metaBytes), + msgField(2, respBytes), + strField(3, r.error), + ); +} + +// --------------------------------------------------------------------------- +// Message decoder +// --------------------------------------------------------------------------- + +/** Decode raw protobuf bytes into a flat map of fieldNumber → raw value. */ +function decodeFields(buf: Uint8Array): Map> { + const fields = new Map>(); + let offset = 0; + + while (offset < buf.length) { + const [t, tLen] = readVarint(buf, offset); + offset += tLen; + const fieldNum = t >> 3; + const wireType = t & 0x7; + + if (wireType === WIRE_VARINT) { + const [v, vLen] = readVarint(buf, offset); + offset += vLen; + const arr = fields.get(fieldNum) ?? []; + arr.push(v); + fields.set(fieldNum, arr); + } else if (wireType === WIRE_LEN) { + const [len, lLen] = readVarint(buf, offset); + offset += lLen; + const bytes = buf.slice(offset, offset + len); + offset += len; + const arr = fields.get(fieldNum) ?? []; + arr.push(bytes); + fields.set(fieldNum, arr); + } else { + // Unknown wire type — skip; shouldn't happen with our schema + break; + } + } + return fields; +} + +function getBytes(fields: Map>, fieldNum: number): Uint8Array | undefined { + const v = fields.get(fieldNum)?.[0]; + return v instanceof Uint8Array ? v : undefined; +} + +function getStr(fields: Map>, fieldNum: number): string | undefined { + const b = getBytes(fields, fieldNum); + return b ? dec.decode(b) : undefined; +} + +function getInt(fields: Map>, fieldNum: number): number { + const v = fields.get(fieldNum)?.[0]; + return typeof v === 'number' ? v : 0; +} + +function decodeLangChainTool(buf: Uint8Array): ILangChainTool { + const f = decodeFields(buf); + return { + name: getStr(f, 1), + description: getStr(f, 2), + args_schema: getStr(f, 3), + result: getStr(f, 4), + error: getStr(f, 5), + query: getStr(f, 6), + }; +} + +function decodeServiceMeta(buf: Uint8Array): IServiceMeta { + const f = decodeFields(buf); + return { + subject: getStr(f, 1) ?? '', + handler: getInt(f, 2), + puppet: getInt(f, 3), + }; +} + +function decodeServiceRequest(buf: Uint8Array): IServiceRequest { + const f = decodeFields(buf); + const metaBytes = getBytes(f, 1); + const reqBytes = getBytes(f, 2); + + let puppetRequest: IServiceRequest['puppet_request']; + if (reqBytes) { + const rf = decodeFields(reqBytes); + const lcBytes = getBytes(rf, 1); + puppetRequest = { lc_tool: lcBytes ? decodeLangChainTool(lcBytes) : undefined }; + } + + return { + meta: metaBytes ? decodeServiceMeta(metaBytes) : undefined, + puppet_request: puppetRequest, + }; +} + +// --------------------------------------------------------------------------- +// Public API — same surface as the old protobufjs version +// --------------------------------------------------------------------------- + +export { encodeServiceResponse, decodeServiceRequest };