♻️ refactor(cli): extract shared @lobechat/local-file-shell package (#12865)

* ♻️ refactor(cli): extract shared @lobechat/local-file-shell package

Extract common file and shell operations from Desktop and CLI into a
shared package to eliminate ~1500 lines of duplicated code. CLI now
uses @lobechat/file-loaders for rich format support (PDF, DOCX, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update

* update commands

* update version

* update deps

* refactor version issue

*  feat(local-file-shell): add cwd support, move/rename ops, improve logging

- Add missing `cwd` parameter to `runCommand` (align with Desktop)
- Add `moveLocalFiles` with batch support and detailed error handling
- Add `renameLocalFile` with path validation and traversal prevention
- Add error logging in shell runner's error/completion handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* support update model and provider in cli

* fix desktop build

* fix

* 🐛 fix: pin fast-xml-parser to 5.4.2 in bun overrides

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-03-11 00:04:22 +08:00 committed by GitHub
parent c2e9b45d4c
commit 860e11ab3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 4313 additions and 2560 deletions

228
.agents/skills/cli/SKILL.md Normal file
View file

@ -0,0 +1,228 @@
---
name: cli
description: LobeHub CLI (@lobehub/cli) development guide. Use when working on CLI commands, adding new subcommands, fixing CLI bugs, or understanding CLI architecture. Triggers on CLI development, command implementation, or `lh` command questions.
disable-model-invocation: true
---
# LobeHub CLI Development Guide
## Overview
LobeHub CLI (`@lobehub/cli`) is a command-line tool for managing and interacting with LobeHub services. The binary is named `lh` and is built with Commander.js + TypeScript.
- **Package**: `apps/cli/`
- **Entry**: `apps/cli/src/index.ts`
- **Binary**: `lh`
- **Build**: tsup
- **Runtime**: Node.js / Bun
## Architecture
```
apps/cli/src/
├── index.ts # Entry point, registers all commands
├── api/
│ ├── client.ts # tRPC client (type-safe backend API)
│ └── http.ts # Raw HTTP utilities
├── auth/
│ ├── credentials.ts # Encrypted credential storage (AES-256-GCM)
│ ├── refresh.ts # Token auto-refresh
│ └── resolveToken.ts # Token resolution (flag > stored)
├── commands/ # All CLI commands (one file per command group)
│ ├── agent.ts # Agent CRUD + run
│ ├── config.ts # whoami, usage
│ ├── connect.ts # Device gateway connection + daemon
│ ├── doc.ts # Document management
│ ├── file.ts # File management
│ ├── generate/ # Content generation (text/image/video/tts/asr)
│ ├── kb.ts # Knowledge base management
│ ├── login.ts # OIDC Device Code Flow auth
│ ├── logout.ts # Clear credentials
│ ├── memory.ts # User memory management
│ ├── message.ts # Message management
│ ├── model.ts # AI model management
│ ├── plugin.ts # Plugin management
│ ├── provider.ts # AI provider management
│ ├── search.ts # Global search
│ ├── skill.ts # Agent skill management
│ ├── status.ts # Gateway connectivity check
│ └── topic.ts # Conversation topic management
├── daemon/
│ └── manager.ts # Background daemon process management
├── tools/
│ ├── shell.ts # Shell command execution (for gateway)
│ └── file.ts # File operations (for gateway)
├── settings/
│ └── index.ts # Persistent settings (~/.lobehub/)
├── utils/
│ ├── logger.ts # Logging (verbose mode)
│ ├── format.ts # Table output, JSON, timeAgo, truncate
│ └── agentStream.ts # SSE streaming for agent runs
└── constants/
└── urls.ts # Official server & gateway URLs
```
## Command Groups
| Command | Alias | Description |
| ------------- | ----- | ------------------------------------------------- |
| `lh login` | - | Authenticate via OIDC Device Code Flow |
| `lh logout` | - | Clear stored credentials |
| `lh connect` | - | Device gateway connection & daemon management |
| `lh status` | - | Quick gateway connectivity check |
| `lh agent` | - | Agent CRUD, run, status |
| `lh generate` | `gen` | Content generation (text, image, video, tts, asr) |
| `lh doc` | - | Document CRUD |
| `lh file` | - | File list, view, delete, recent |
| `lh kb` | - | Knowledge base management |
| `lh memory` | - | User memory CRUD + extraction |
| `lh message` | - | Message list, search, delete, count, heatmap |
| `lh topic` | - | Topic CRUD + search + recent |
| `lh skill` | - | Skill CRUD + import (GitHub/URL/market) |
| `lh model` | - | Model list, view, toggle, delete |
| `lh provider` | - | Provider list, view, toggle, delete |
| `lh plugin` | - | Plugin install, uninstall, update |
| `lh search` | - | Global search across all types |
| `lh whoami` | - | Current user info |
| `lh usage` | - | Monthly/daily usage statistics |
## Adding a New Command
### 1. Create Command File
Create `apps/cli/src/commands/<name>.ts`:
```typescript
import type { Command } from 'commander';
import { getTrpcClient } from '../api/client';
import { outputJson, printTable, truncate } from '../utils/format';
export function register<Name>Command(program: Command) {
const cmd = program.command('<name>').description('...');
// Subcommands
cmd
.command('list')
.description('List items')
.option('-L, --limit <n>', 'Maximum number of items', '30')
.option('--json [fields]', 'Output JSON, optionally specify fields')
.action(async (options) => {
const client = await getTrpcClient();
const result = await client.<router>.<procedure>.query({ ... });
// Handle output
});
}
```
### 2. Register in Entry Point
In `apps/cli/src/index.ts`:
```typescript
import { registerNewCommand } from './commands/new';
// ...
registerNewCommand(program);
```
### 3. Add Tests
Create `apps/cli/src/commands/<name>.test.ts` alongside the command file.
## Conventions
### Output Patterns
All list/view commands follow consistent patterns:
- `--json [fields]` - JSON output with optional field filtering
- `--yes` - Skip confirmation for destructive ops
- `-L, --limit <n>` - Pagination limit (default: 30)
- `-v, --verbose` - Verbose logging
### Table Output
```typescript
const rows = items.map((item) => [item.id, truncate(item.title, 40), timeAgo(item.updatedAt)]);
printTable(rows, ['ID', 'TITLE', 'UPDATED']);
```
### JSON Output
```typescript
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(items, fields);
return;
}
```
### Authentication
Commands that need auth use `getTrpcClient()` which auto-resolves tokens:
```typescript
const client = await getTrpcClient();
// client.router.procedure.query/mutate(...)
```
### Confirmation Prompts
```typescript
import { confirm } from '../utils/format';
if (!options.yes) {
const ok = await confirm('Are you sure?');
if (!ok) return;
}
```
## Storage Locations
| File | Path | Purpose |
| ------------- | ----------------------------- | ------------------------------ |
| Credentials | `~/.lobehub/credentials.json` | Encrypted tokens (AES-256-GCM) |
| Settings | `~/.lobehub/settings.json` | Custom server/gateway URLs |
| Daemon PID | `~/.lobehub/daemon.pid` | Background process PID |
| Daemon Status | `~/.lobehub/daemon.status` | Connection status JSON |
| Daemon Log | `~/.lobehub/daemon.log` | Daemon output log |
## Key Dependencies
- `commander` - CLI framework
- `@trpc/client` + `superjson` - Type-safe API client
- `@lobechat/device-gateway-client` - WebSocket gateway connection
- `@lobechat/local-file-shell` - Local shell/file tool execution
- `picocolors` - Terminal colors
- `ws` - WebSocket
- `diff` - Text diffing
- `fast-glob` - File pattern matching
## Development
```bash
# Run directly (dev mode)
cd apps/cli && bun run dev -- <command>
# Build
cd apps/cli && bun run build
# Test
cd apps/cli && bun run test
# Link globally for testing
cd apps/cli && bun run cli:link
```
## Detailed Command References
See `references/` for each command group:
- **Authentication**: `references/auth.md` (login, logout)
- **Connection & Gateway**: `references/connect.md` (connect, status, daemon)
- **Agent**: `references/agent.md` (CRUD, run, status)
- **Content Generation**: `references/generate.md` (text, image, video, tts, asr)
- **Knowledge & Files**: `references/knowledge.md` (kb, file, doc)
- **Conversation**: `references/conversation.md` (topic, message)
- **Memory**: `references/memory.md` (memory management, extraction)
- **Skills & Plugins**: `references/skills-plugins.md` (skill, plugin)
- **Models & Providers**: `references/models-providers.md` (model, provider)
- **Search & Config**: `references/search-config.md` (search, whoami, usage)

View file

@ -0,0 +1,144 @@
# Agent Commands
Manage AI agents: create, edit, delete, list, run, and check status.
**Source**: `apps/cli/src/commands/agent.ts`
## `lh agent list`
List all agents.
```bash
lh agent list [-L [-k [--json [fields]] < n > ] < keyword > ]
```
| Option | Description | Default |
| ------------------------- | -------------------------------------- | ------- |
| `-L, --limit <n>` | Maximum items | `30` |
| `-k, --keyword <keyword>` | Filter by keyword | - |
| `--json [fields]` | JSON output with optional field filter | - |
**Table columns**: ID, TITLE, DESCRIPTION, MODEL
---
## `lh agent view <agentId>`
View agent configuration details.
```bash
lh agent view [fields]] < agentId > [--json
```
**Displays**: Title, description, model, provider, system role, plugins, tools.
---
## `lh agent create`
Create a new agent.
```bash
lh agent create [options]
```
| Option | Description | Required |
| --------------------------- | -------------- | -------- |
| `-t, --title <title>` | Agent title | No |
| `-d, --description <desc>` | Description | No |
| `-m, --model <model>` | Model ID | No |
| `-p, --provider <provider>` | Provider ID | No |
| `-s, --system-role <role>` | System prompt | No |
| `--group <groupId>` | Agent group ID | No |
**Output**: Created agent ID and session ID.
---
## `lh agent edit <agentId>`
Update an existing agent. Same options as `create`, all optional. Only specified fields are updated.
```bash
lh agent edit [-m [-s ... < agentId > [-t < title > ] < model > ] < role > ]
```
---
## `lh agent delete <agentId>`
Delete an agent.
```bash
lh agent delete < agentId > [--yes]
```
Requires confirmation unless `--yes` is provided.
---
## `lh agent duplicate <agentId>`
Duplicate an existing agent.
```bash
lh agent duplicate < agentId > [-t < title > ]
```
| Option | Description |
| --------------------- | ------------------------------------ |
| `-t, --title <title>` | Optional new title for the duplicate |
**Output**: New agent ID.
---
## `lh agent run`
Start an agent execution (streaming SSE).
```bash
lh agent run [options]
```
| Option | Description |
| --------------------- | -------------------------------------------- |
| `-a, --agent-id <id>` | Agent ID to run |
| `-s, --slug <slug>` | Agent slug (alternative to ID) |
| `-p, --prompt <text>` | User prompt |
| `-t, --topic-id <id>` | Reuse existing topic |
| `--no-auto-start` | Don't auto-start the agent |
| `--json` | Output full JSON event stream |
| `-v, --verbose` | Show detailed tool call info |
| `--replay <file>` | Replay events from saved JSON file (offline) |
### Streaming Behavior
Uses `utils/agentStream.ts` to handle Server-Sent Events:
1. Sends agent run request to backend
2. Streams SSE events in real-time
3. Displays: text chunks, tool call status, operation progress
4. Shows final token usage and cost summary
### Replay Mode
`--replay <file>` reads a saved JSON event stream for offline debugging without server connection.
---
## `lh agent status <operationId>`
Check agent operation status.
```bash
lh agent status [fields]] [--history] [--history-limit < operationId > [--json < n > ]
```
| Option | Description | Default |
| --------------------- | -------------------- | ------- |
| `--json [fields]` | JSON output | - |
| `--history` | Include step history | `false` |
| `--history-limit <n>` | Max history entries | `10` |
**Displays**: Status (running/completed/failed), steps count, tokens used, cost, error info, timestamps.

View file

@ -0,0 +1,122 @@
# Conversation Commands (Topic & Message)
## Topic Management (`lh topic`)
Manage conversation topics (threads).
**Source**: `apps/cli/src/commands/topic.ts`
### `lh topic list`
```bash
lh topic list [--agent-id [--session-id [-L [--page [--json [fields]] < id > ] < id > ] < n > ] < n > ]
```
| Option | Description | Default |
| ------------------- | ----------------- | ------- |
| `--agent-id <id>` | Filter by agent | - |
| `--session-id <id>` | Filter by session | - |
| `-L, --limit <n>` | Page size | `30` |
| `--page <n>` | Page number | `1` |
**Table columns**: ID, TITLE, FAVORITE, UPDATED
### `lh topic search <keywords>`
```bash
lh topic search [--json [fields]] < keywords > [--agent-id < id > ]
```
### `lh topic create`
```bash
lh topic create -t [--session-id [--favorite] < title > [--agent-id < id > ] < id > ]
```
| Option | Description | Required |
| --------------------- | ---------------------- | -------- |
| `-t, --title <title>` | Topic title | Yes |
| `--agent-id <id>` | Associate with agent | No |
| `--session-id <id>` | Associate with session | No |
| `--favorite` | Mark as favorite | No |
### `lh topic edit <id>`
```bash
lh topic edit [--favorite] [--no-favorite] < id > [-t < title > ]
```
### `lh topic delete <ids...>`
```bash
lh topic delete [--yes] < id1 > [id2...]
```
### `lh topic recent`
```bash
lh topic recent [-L [--json [fields]] < n > ]
```
| Option | Description | Default |
| ----------------- | --------------- | ------- |
| `-L, --limit <n>` | Number of items | `10` |
---
## Message Management (`lh message`)
Manage chat messages within topics.
**Source**: `apps/cli/src/commands/message.ts`
### `lh message list`
```bash
lh message list [options] [--json [fields]]
```
| Option | Description | Default |
| ------------------- | ----------------- | ------- |
| `--topic-id <id>` | Filter by topic | - |
| `--agent-id <id>` | Filter by agent | - |
| `--session-id <id>` | Filter by session | - |
| `-L, --limit <n>` | Page size | `30` |
| `--page <n>` | Page number | `1` |
**Table columns**: ID, ROLE, CONTENT, CREATED
### `lh message search <keywords>`
```bash
lh message search [fields]] < keywords > [--json
```
Full-text search across all messages.
### `lh message delete <ids...>`
```bash
lh message delete [--yes] < id1 > [id2...]
```
### `lh message count`
```bash
lh message count [--start [--end [--json] < date > ] < date > ]
```
| Option | Description |
| ---------------- | ------------------------------------------ |
| `--start <date>` | Start date (ISO format, e.g. `2024-01-01`) |
| `--end <date>` | End date (ISO format) |
**Output**: Total message count for the specified period.
### `lh message heatmap`
```bash
lh message heatmap [--json]
```
**Output**: Activity heatmap data showing message frequency over time.

View file

@ -0,0 +1,132 @@
# Content Generation Commands
Generate text, images, videos, speech, and transcriptions.
**Source**: `apps/cli/src/commands/generate/`
## Command Structure
```
lh generate (alias: gen)
├── text <prompt> # Text generation
├── image <prompt> # Image generation
├── video <prompt> # Video generation
├── tts <text> # Text-to-speech
├── asr <audioFile> # Audio-to-text (speech recognition)
├── status <genId> <taskId> # Check async task status
└── list # List generation topics
```
---
## `lh generate text <prompt>` / `lh gen text <prompt>`
Generate text completion.
**Source**: `apps/cli/src/commands/generate/text.ts`
```bash
lh gen text "Explain quantum computing" [options]
echo "context" | lh gen text "summarize" --pipe
```
| Option | Description | Default |
| --------------------------- | ---------------------------------- | -------------------- |
| `-m, --model <model>` | Model ID | `openai/gpt-4o-mini` |
| `-p, --provider <provider>` | Provider name | - |
| `-s, --system <prompt>` | System prompt | - |
| `--temperature <n>` | Temperature (0-2) | - |
| `--max-tokens <n>` | Maximum output tokens | - |
| `--stream` | Enable streaming output | `false` |
| `--json` | Output full JSON response | `false` |
| `--pipe` | Read additional context from stdin | `false` |
### Pipe Mode
When `--pipe` is used, reads stdin and prepends it to the prompt. Useful for piping file contents:
```bash
cat README.md | lh gen text "summarize this" --pipe
```
---
## `lh generate image <prompt>` / `lh gen image <prompt>`
Generate images from text prompt.
**Source**: `apps/cli/src/commands/generate/image.ts`
```bash
lh gen image "A sunset over mountains" [options]
```
Options follow same pattern as text generation with image-specific model defaults.
---
## `lh generate video <prompt>` / `lh gen video <prompt>`
Generate video from text prompt.
**Source**: `apps/cli/src/commands/generate/video.ts`
```bash
lh gen video "A cat playing piano" [options]
```
---
## `lh generate tts <text>` / `lh gen tts <text>`
Text-to-speech generation.
**Source**: `apps/cli/src/commands/generate/tts.ts`
```bash
lh gen tts "Hello, world!" [options]
```
---
## `lh generate asr <audioFile>` / `lh gen asr <audioFile>`
Audio-to-text transcription (Automatic Speech Recognition).
**Source**: `apps/cli/src/commands/generate/asr.ts`
```bash
lh gen asr recording.wav [options]
```
---
## `lh generate status <generationId> <taskId>`
Check the status of an async generation task.
```bash
lh gen status <generationId> <taskId> [--json]
```
| Option | Description |
| -------- | ------------------------ |
| `--json` | Output raw JSON response |
**Displays**:
- Status (color-coded): `success` (green), `error` (red), `processing` (yellow), `pending` (cyan)
- Error message (if failed)
- Asset URL and thumbnail URL (if completed)
---
## `lh generate list`
List all generation topics.
```bash
lh gen list [--json [fields]]
```
**Table columns**: ID, TITLE, TYPE, UPDATED

View file

@ -0,0 +1,170 @@
# Knowledge Base, File & Document Commands
## Knowledge Base (`lh kb`)
Manage knowledge bases for RAG (Retrieval-Augmented Generation).
**Source**: `apps/cli/src/commands/kb.ts`
### `lh kb list`
```bash
lh kb list [--json [fields]]
```
**Table columns**: ID, NAME, DESCRIPTION, UPDATED
### `lh kb view <id>`
```bash
lh kb view [fields]] < id > [--json
```
**Displays**: Name, description, associated files.
### `lh kb create`
```bash
lh kb create -n [--avatar < name > [-d < desc > ] < url > ]
```
| Option | Description | Required |
| -------------------------- | ------------------- | -------- |
| `-n, --name <name>` | Knowledge base name | Yes |
| `-d, --description <desc>` | Description | No |
| `--avatar <url>` | Avatar URL | No |
**Output**: Created KB ID.
### `lh kb edit <id>`
```bash
lh kb edit [-d [--avatar < id > [-n < name > ] < desc > ] < url > ]
```
### `lh kb delete <id>`
```bash
lh kb delete [--yes] < id > [--remove-files]
```
| Option | Description |
| ---------------- | ---------------------------- |
| `--remove-files` | Also delete associated files |
| `--yes` | Skip confirmation |
### `lh kb add-files <knowledgeBaseId>`
```bash
lh kb add-files <kbId> --ids <fileId1> <fileId2> ...
```
### `lh kb remove-files <knowledgeBaseId>`
```bash
lh kb remove-files <kbId> --ids <fileId1> <fileId2> ... [--yes]
```
---
## File Management (`lh file`)
Manage uploaded files.
**Source**: `apps/cli/src/commands/file.ts`
### `lh file list`
```bash
lh file list [--kb-id [-L [--json [fields]] < id > ] < n > ]
```
| Option | Description | Default |
| ----------------- | ------------------------ | ------- |
| `--kb-id <id>` | Filter by knowledge base | - |
| `-L, --limit <n>` | Maximum items | `30` |
**Table columns**: ID, NAME, TYPE, SIZE, UPDATED
### `lh file view <id>`
```bash
lh file view [fields]] < id > [--json
```
**Displays**: Name, type, size, chunking status, embedding status.
### `lh file delete <ids...>`
```bash
lh file delete [--yes] < id1 > [id2...]
```
Supports deleting multiple files at once.
### `lh file recent`
```bash
lh file recent [-L [--json [fields]] < n > ]
```
| Option | Description | Default |
| ----------------- | --------------- | ------- |
| `-L, --limit <n>` | Number of items | `10` |
---
## Document Management (`lh doc`)
Manage text documents (notes, wiki pages).
**Source**: `apps/cli/src/commands/doc.ts`
### `lh doc list`
```bash
lh doc list [-L [--file-type [--json [fields]] < n > ] < type > ]
```
| Option | Description | Default |
| -------------------- | ------------------- | ------- |
| `-L, --limit <n>` | Maximum items | `30` |
| `--file-type <type>` | Filter by file type | - |
**Table columns**: ID, TITLE, TYPE, UPDATED
### `lh doc view <id>`
```bash
lh doc view [fields]] < id > [--json
```
**Displays**: Title, type, updated time, full content.
### `lh doc create`
```bash
lh doc create -t [-F [--parent [--slug < title > [-b < body > ] < path > ] < id > ] < slug > ]
```
| Option | Description | Required |
| ------------------------ | ------------------- | -------- |
| `-t, --title <title>` | Document title | Yes |
| `-b, --body <content>` | Document body text | No |
| `-F, --body-file <path>` | Read body from file | No |
| `--parent <id>` | Parent document ID | No |
| `--slug <slug>` | Custom URL slug | No |
`-b` and `-F` are mutually exclusive; `-F` reads the file content as the body.
### `lh doc edit <id>`
```bash
lh doc edit [-b [-F [--parent < id > [-t < title > ] < body > ] < path > ] < id > ]
```
### `lh doc delete <ids...>`
```bash
lh doc delete [--yes] < id1 > [id2...]
```

View file

@ -0,0 +1,138 @@
# Memory Commands
Manage user memories - the AI's long-term knowledge about users.
**Source**: `apps/cli/src/commands/memory.ts`
## Memory Categories
| Category | Description |
| ------------ | ----------------------------------------- |
| `identity` | User's name, role, relationships |
| `activity` | Recent activities and their status |
| `context` | Ongoing contexts, projects, goals |
| `experience` | Past experiences and key learnings |
| `preference` | User preferences, directives, suggestions |
---
## `lh memory list [category]`
List memory entries, optionally filtered by category.
```bash
lh memory list # All categories
lh memory list identity # Only identity memories
lh memory list preference # Only preferences
```
| Option | Description |
| ----------------- | ----------- |
| `--json [fields]` | JSON output |
**Output**: Grouped by category, showing type/status and descriptions.
---
## `lh memory create`
Create a new identity memory entry.
```bash
lh memory create [options]
```
| Option | Description |
| -------------------------- | ------------------------ |
| `--type <type>` | Memory type |
| `--role <role>` | User's role |
| `--relationship <rel>` | Relationship description |
| `-d, --description <desc>` | Description |
| `--labels <labels...>` | Extracted labels |
---
## `lh memory edit <category> <id>`
Edit a memory entry. Options vary by category:
```bash
lh memory edit identity < id > [options]
lh memory edit activity < id > [options]
lh memory edit context < id > [options]
lh memory edit experience < id > [options]
lh memory edit preference < id > [options]
```
### Category-specific Options
**identity**:
- `--type <type>`, `--role <role>`, `--relationship <rel>`
**activity**:
- `--narrative <text>`, `--notes <text>`, `--status <status>`
**context**:
- `--title <title>`, `--description <desc>`, `--status <status>`
**experience**:
- `--situation <text>`, `--action <text>`, `--key-learning <text>`
**preference**:
- `--directives <text>`, `--suggestions <text>`
---
## `lh memory delete <category> <id>`
```bash
lh memory delete identity < id > [--yes]
```
---
## `lh memory persona`
Display the compiled memory persona summary.
```bash
lh memory persona [--json [fields]]
```
**Output**: Summarized user profile built from all memory categories.
---
## `lh memory extract`
Trigger async memory extraction from chat history.
```bash
lh memory extract [--from [--to < date > ] < date > ]
```
| Option | Description |
| --------------- | ----------------------- |
| `--from <date>` | Start date (ISO format) |
| `--to <date>` | End date (ISO format) |
Starts a background task that analyzes chat history and creates new memory entries.
---
## `lh memory extract-status`
Check the status of a memory extraction task.
```bash
lh memory extract-status [--task-id [--json [fields]] < id > ]
```
| Option | Description |
| ---------------- | ------------------- |
| `--task-id <id>` | Check specific task |

View file

@ -0,0 +1,93 @@
# Model & Provider Commands
## Model Management (`lh model`)
Manage AI models within providers.
**Source**: `apps/cli/src/commands/model.ts`
### `lh model list <providerId>`
List models for a specific provider.
```bash
lh model list openai [-L [--enabled] [--json [fields]] < n > ]
```
| Option | Description | Default |
| ----------------- | ------------------------ | ------- |
| `-L, --limit <n>` | Maximum items | `50` |
| `--enabled` | Only show enabled models | `false` |
**Table columns**: ID, NAME, ENABLED, TYPE
### `lh model view <id>`
```bash
lh model view [fields]] < modelId > [--json
```
**Displays**: Name, provider, type, enabled status, capabilities.
### `lh model toggle <id>`
Enable or disable a model.
```bash
lh model toggle < modelId > --provider < providerId > --enable
lh model toggle < modelId > --provider < providerId > --disable
```
| Option | Description | Required |
| ------------------------- | ----------------- | ------------ |
| `--provider <providerId>` | Provider ID | Yes |
| `--enable` | Enable the model | One required |
| `--disable` | Disable the model | One required |
### `lh model delete <id>`
```bash
lh model delete < modelId > --provider < providerId > [--yes]
```
| Option | Description | Required |
| ------------------------- | ----------------- | -------- |
| `--provider <providerId>` | Provider ID | Yes |
| `--yes` | Skip confirmation | No |
---
## Provider Management (`lh provider`)
Manage AI service providers.
**Source**: `apps/cli/src/commands/provider.ts`
### `lh provider list`
```bash
lh provider list [--json [fields]]
```
**Table columns**: ID, NAME, ENABLED, SOURCE
### `lh provider view <id>`
```bash
lh provider view [fields]] < providerId > [--json
```
**Displays**: Name, enabled status, source, configuration.
### `lh provider toggle <id>`
```bash
lh provider toggle < providerId > --enable
lh provider toggle < providerId > --disable
```
### `lh provider delete <id>`
```bash
lh provider delete < providerId > [--yes]
```

View file

@ -0,0 +1,94 @@
# Search & Configuration Commands
## Global Search (`lh search`)
Search across all LobeHub resource types.
**Source**: `apps/cli/src/commands/search.ts`
### `lh search <query>`
```bash
lh search "meeting notes" [-t [-L [--json [fields]] < type > ] < n > ]
```
| Option | Description | Default |
| ------------------- | ----------------------- | --------- |
| `-t, --type <type>` | Filter by resource type | All types |
| `-L, --limit <n>` | Results per type | `10` |
### Searchable Types
| Type | Description |
| ---------------- | ---------------------------- |
| `agent` | AI agents |
| `topic` | Conversation topics |
| `file` | Uploaded files |
| `folder` | File folders |
| `message` | Chat messages |
| `page` | Documents/pages |
| `memory` | User memories |
| `mcp` | MCP servers |
| `plugin` | Installed plugins |
| `communityAgent` | Community marketplace agents |
| `knowledgeBase` | Knowledge bases |
**Output**: Results grouped by type, showing ID, title/name, description.
---
## User Configuration (`lh whoami` / `lh usage`)
**Source**: `apps/cli/src/commands/config.ts`
### `lh whoami`
Display current authenticated user information.
```bash
lh whoami [--json [fields]]
```
**Displays**: Name, username, email, user ID, subscription plan.
### `lh usage`
Display usage statistics.
```bash
lh usage [--month [--daily] [--json [fields]] < YYYY-MM > ]
```
| Option | Description | Default |
| ------------------- | -------------- | ----------------------- |
| `--month <YYYY-MM>` | Month to query | Current month |
| `--daily` | Group by day | `false` (monthly total) |
**Output**: Token usage, costs, and model breakdown for the specified period.
---
## Global Options
These options are available across most commands:
| Option | Description |
| ----------------- | ---------------------------------------------------------------------- |
| `--json [fields]` | Output as JSON; optionally filter to specific fields (comma-separated) |
| `--yes` | Skip confirmation prompts for destructive operations |
| `-L, --limit <n>` | Pagination limit for list commands |
| `-v, --verbose` | Enable verbose/debug logging |
| `--help` | Show command help |
| `--version` | Show CLI version |
### JSON Field Filtering
The `--json` option supports field selection:
```bash
# Full JSON output
lh agent list --json
# Only specific fields
lh agent list --json "id,title,model"
```

View file

@ -0,0 +1,152 @@
# Skill & Plugin Commands
## Skill Management (`lh skill`)
Manage agent skills (custom instructions and capabilities).
**Source**: `apps/cli/src/commands/skill.ts`
### `lh skill list`
```bash
lh skill list [--source [--json [fields]] < source > ]
```
| Option | Description |
| ------------------- | ----------------------------------- |
| `--source <source>` | Filter: `builtin`, `market`, `user` |
**Table columns**: ID, NAME, DESCRIPTION, SOURCE, IDENTIFIER
### `lh skill view <id>`
```bash
lh skill view [fields]] < id > [--json
```
**Displays**: Name, description, source, identifier, content.
### `lh skill create`
```bash
lh skill create -n < name > -d < desc > -c < content > [-i < identifier > ]
```
| Option | Description | Required |
| -------------------------- | ----------------------------------- | -------- |
| `-n, --name <name>` | Skill name | Yes |
| `-d, --description <desc>` | Description | Yes |
| `-c, --content <content>` | Skill content (prompt/instructions) | Yes |
| `-i, --identifier <id>` | Custom identifier | No |
### `lh skill edit <id>`
```bash
lh skill edit [-n [-d < id > [-c < content > ] < name > ] < desc > ]
```
### `lh skill delete <id>`
```bash
lh skill delete < id > [--yes]
```
### `lh skill search <query>`
```bash
lh skill search [fields]] < query > [--json
```
### Import Commands
#### `lh skill import-github`
Import a skill from a GitHub repository.
```bash
lh skill import-github --url < gitUrl > [--branch < branch > ]
```
| Option | Description | Required |
| ------------------- | ------------------ | ------------------- |
| `--url <gitUrl>` | Git repository URL | Yes |
| `--branch <branch>` | Branch name | No (default branch) |
#### `lh skill import-url`
Import a skill from a ZIP file URL.
```bash
lh skill import-url --url <zipUrl>
```
#### `lh skill import-market`
Import a skill from the LobeHub skill marketplace.
```bash
lh skill import-market -i <identifier>
```
### Resource Commands
#### `lh skill resources <id>`
List files/resources within a skill.
```bash
lh skill resources [fields]] < id > [--json
```
**Displays**: Path, type, size.
#### `lh skill read-resource <id> <path>`
Read a specific resource file from a skill.
```bash
lh skill read-resource <skillId> <path>
```
**Output**: File content or JSON metadata.
---
## Plugin Management (`lh plugin`)
Install and manage plugins (external tool integrations).
**Source**: `apps/cli/src/commands/plugin.ts`
### `lh plugin list`
```bash
lh plugin list [--json [fields]]
```
**Table columns**: ID, IDENTIFIER, TYPE, TITLE
### `lh plugin install`
```bash
lh plugin install -i [--settings < identifier > --manifest < json > [--type < type > ] < json > ]
```
| Option | Description | Required |
| ----------------------- | -------------------------- | ---------------------- |
| `-i, --identifier <id>` | Plugin identifier | Yes |
| `--manifest <json>` | Plugin manifest JSON | Yes |
| `--type <type>` | `plugin` or `customPlugin` | No (default: `plugin`) |
| `--settings <json>` | Plugin settings JSON | No |
### `lh plugin uninstall <id>`
```bash
lh plugin uninstall < id > [--yes]
```
### `lh plugin update <id>`
```bash
lh plugin update [--settings < id > [--manifest < json > ] < json > ]
```

View file

@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.1-canary.5",
"version": "0.0.1-canary.8",
"type": "module",
"bin": {
"lh": "./dist/index.js"
@ -21,7 +21,8 @@
"dependencies": {
"@trpc/client": "^11.8.1",
"commander": "^13.1.0",
"diff": "^7.0.0",
"debug": "^4.4.0",
"diff": "^8.0.3",
"fast-glob": "^3.3.3",
"picocolors": "^1.1.1",
"superjson": "^2.2.6",
@ -29,7 +30,7 @@
},
"devDependencies": {
"@lobechat/device-gateway-client": "workspace:*",
"@types/diff": "^6.0.0",
"@lobechat/local-file-shell": "workspace:*",
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
"tsup": "^8.4.0",

View file

@ -1,3 +1,5 @@
packages:
- '../../packages/device-gateway-client'
- '../../packages/local-file-shell'
- '../../packages/file-loaders'
- '.'

View file

@ -10,6 +10,7 @@ const { mockTrpcClient } = vi.hoisted(() => ({
createAgent: { mutate: vi.fn() },
duplicateAgent: { mutate: vi.fn() },
getAgentConfigById: { query: vi.fn() },
getBuiltinAgent: { query: vi.fn() },
queryAgents: { query: vi.fn() },
removeAgent: { mutate: vi.fn() },
updateAgentConfig: { mutate: vi.fn() },
@ -136,6 +137,27 @@ describe('agent command', () => {
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should support --slug option', async () => {
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({
id: 'resolved-id',
model: 'gpt-4',
title: 'Inbox Agent',
});
mockTrpcClient.agent.getAgentConfigById.query.mockResolvedValue({
id: 'resolved-id',
model: 'gpt-4',
title: 'Inbox Agent',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'agent', 'view', '--slug', 'inbox']);
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
expect(mockTrpcClient.agent.getAgentConfigById.query).toHaveBeenCalledWith({
agentId: 'resolved-id',
});
});
});
describe('create', () => {
@ -186,6 +208,32 @@ describe('agent command', () => {
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should support --slug option', async () => {
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({
id: 'resolved-id',
title: 'Inbox Agent',
});
mockTrpcClient.agent.updateAgentConfig.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'edit',
'--slug',
'inbox',
'--model',
'gemini-3-pro',
]);
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
expect(mockTrpcClient.agent.updateAgentConfig.mutate).toHaveBeenCalledWith({
agentId: 'resolved-id',
value: { model: 'gemini-3-pro' },
});
});
});
describe('delete', () => {

View file

@ -9,6 +9,30 @@ import { replayAgentEvents, streamAgentEvents } from '../utils/agentStream';
import { confirm, outputJson, printTable, truncate } from '../utils/format';
import { log, setVerbose } from '../utils/logger';
/**
* Resolve an agent identifier (agentId or slug) to a concrete agentId.
* When a slug is provided, uses getBuiltinAgent to look up the agent.
*/
async function resolveAgentId(
client: any,
opts: { agentId?: string; slug?: string },
): Promise<string> {
if (opts.agentId) return opts.agentId;
if (opts.slug) {
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
if (!agent) {
log.error(`Agent not found for slug: ${opts.slug}`);
process.exit(1);
}
return (agent as any).id || (agent as any).agentId;
}
log.error('Either <agentId> or --slug is required.');
process.exit(1);
return ''; // unreachable
}
export function registerAgentCommand(program: Command) {
const agent = program.command('agent').description('Manage agents');
@ -54,39 +78,46 @@ export function registerAgentCommand(program: Command) {
// ── view ──────────────────────────────────────────────
agent
.command('view <agentId>')
.command('view [agentId]')
.description('View agent configuration')
.option('-s, --slug <slug>', 'Agent slug (e.g. inbox)')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (agentId: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.agent.getAgentConfigById.query({ agentId });
.action(
async (
agentIdArg: string | undefined,
options: { json?: string | boolean; slug?: string },
) => {
const client = await getTrpcClient();
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
const result = await client.agent.getAgentConfigById.query({ agentId });
if (!result) {
log.error(`Agent not found: ${agentId}`);
process.exit(1);
return;
}
if (!result) {
log.error(`Agent not found: ${agentId}`);
process.exit(1);
return;
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
const r = result as any;
console.log(pc.bold(r.title || r.meta?.title || 'Untitled'));
const meta: string[] = [];
if (r.description || r.meta?.description) meta.push(r.description || r.meta.description);
if (r.model) meta.push(`Model: ${r.model}`);
if (r.provider) meta.push(`Provider: ${r.provider}`);
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
const r = result as any;
console.log(pc.bold(r.title || r.meta?.title || 'Untitled'));
const meta: string[] = [];
if (r.description || r.meta?.description) meta.push(r.description || r.meta.description);
if (r.model) meta.push(`Model: ${r.model}`);
if (r.provider) meta.push(`Provider: ${r.provider}`);
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
if (r.systemRole) {
console.log();
console.log(pc.bold('System Role:'));
console.log(r.systemRole);
}
});
if (r.systemRole) {
console.log();
console.log(pc.bold('System Role:'));
console.log(r.systemRole);
}
},
);
// ── create ────────────────────────────────────────────
@ -130,8 +161,9 @@ export function registerAgentCommand(program: Command) {
// ── edit ──────────────────────────────────────────────
agent
.command('edit <agentId>')
.command('edit [agentId]')
.description('Update agent configuration')
.option('--slug <slug>', 'Agent slug (e.g. inbox)')
.option('-t, --title <title>', 'New title')
.option('-d, --description <desc>', 'New description')
.option('-m, --model <model>', 'New model ID')
@ -139,11 +171,12 @@ export function registerAgentCommand(program: Command) {
.option('-s, --system-role <role>', 'New system role prompt')
.action(
async (
agentId: string,
agentIdArg: string | undefined,
options: {
description?: string;
model?: string;
provider?: string;
slug?: string;
systemRole?: string;
title?: string;
},
@ -163,6 +196,7 @@ export function registerAgentCommand(program: Command) {
}
const client = await getTrpcClient();
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
await client.agent.updateAgentConfig.mutate({ agentId, value });
console.log(`${pc.green('✓')} Updated agent ${pc.bold(agentId)}`);
},

View file

@ -6,6 +6,7 @@ import { registerConfigCommand } from './config';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
usage: {
findAndGroupByDateRange: { query: vi.fn() },
findAndGroupByDay: { query: vi.fn() },
findByMonth: { query: vi.fn() },
},
@ -34,6 +35,8 @@ describe('config command', () => {
mockTrpcClient.user.getUserState.query.mockReset();
mockTrpcClient.usage.findByMonth.query.mockReset();
mockTrpcClient.usage.findAndGroupByDay.query.mockReset();
mockTrpcClient.usage.findAndGroupByDateRange.query.mockReset();
mockTrpcClient.usage.findAndGroupByDateRange.query.mockResolvedValue([]);
});
afterEach(() => {
@ -75,36 +78,34 @@ describe('config command', () => {
});
describe('usage', () => {
it('should display monthly usage', async () => {
mockTrpcClient.usage.findByMonth.query.mockResolvedValue({ totalTokens: 1000 });
it('should display usage table', async () => {
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([
{
day: '2024-01-15',
records: [{ model: 'claude-opus-4-6', totalInputTokens: 500, totalOutputTokens: 500 }],
totalRequests: 1,
totalSpend: 0.5,
totalTokens: 1000,
},
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'usage']);
expect(mockTrpcClient.usage.findByMonth.query).toHaveBeenCalled();
});
it('should display daily usage', async () => {
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([
{ date: '2024-01-01', totalTokens: 100 },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'usage', '--daily']);
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('2024-01-15'));
});
it('should pass month param', async () => {
mockTrpcClient.usage.findByMonth.query.mockResolvedValue({});
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'usage', '--month', '2024-01']);
expect(mockTrpcClient.usage.findByMonth.query).toHaveBeenCalledWith({ mo: '2024-01' });
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalledWith({ mo: '2024-01' });
});
it('should output JSON', async () => {
it('should output JSON with --json flag', async () => {
const data = { totalTokens: 1000 };
mockTrpcClient.usage.findByMonth.query.mockResolvedValue(data);
@ -113,5 +114,16 @@ describe('config command', () => {
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
});
it('should output JSON daily with --json --daily', async () => {
const data = [{ day: '2024-01-01', totalTokens: 100 }];
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue(data);
const program = createProgram();
await program.parseAsync(['node', 'test', 'usage', '--json', '--daily']);
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
});
});
});

View file

@ -2,7 +2,14 @@ import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { outputJson } from '../utils/format';
import {
type BoxTableRow,
formatCost,
formatNumber,
outputJson,
printBoxTable,
printCalendarHeatmap,
} from '../utils/format';
export function registerConfigCommand(program: Command) {
// ── whoami ────────────────────────────────────────────
@ -44,35 +51,146 @@ export function registerConfigCommand(program: Command) {
const input: { mo?: string } = {};
if (options.month) input.mo = options.month;
let result: any;
if (options.daily) {
result = await client.usage.findAndGroupByDay.query(input);
} else {
result = await client.usage.findByMonth.query(input);
}
if (options.json !== undefined) {
let jsonResult: any;
if (options.daily) {
jsonResult = await client.usage.findAndGroupByDay.query(input);
} else {
jsonResult = await client.usage.findByMonth.query(input);
}
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
outputJson(jsonResult, fields);
return;
}
// Always fetch daily-grouped data for table display
const result: any = await client.usage.findAndGroupByDay.query(input);
if (!result) {
console.log('No usage data available.');
return;
}
if (options.daily && Array.isArray(result)) {
console.log(pc.bold('Daily Usage'));
for (const entry of result) {
const e = entry as any;
const day = e.date || e.day || '';
const tokens = e.totalTokens || e.tokens || 0;
console.log(` ${day}: ${tokens} tokens`);
}
} else {
console.log(pc.bold('Monthly Usage'));
console.log(JSON.stringify(result, null, 2));
// Normalize result to an array of daily logs
const logs: any[] = Array.isArray(result) ? result : [result];
// Filter out days with zero activity for cleaner output
const activeLogs = logs.filter(
(l: any) => (l.totalTokens || 0) > 0 || (l.totalRequests || 0) > 0,
);
if (activeLogs.length === 0) {
console.log('No usage data available.');
return;
}
// Build table columns
const columns = [
{ align: 'left' as const, header: 'Date', key: 'date' },
{ align: 'left' as const, header: 'Models', key: 'models' },
{ align: 'right' as const, header: 'Input', key: 'input' },
{ align: 'right' as const, header: 'Output', key: 'output' },
{ align: 'right' as const, header: ['Total', 'Tokens'], key: 'total' },
{ align: 'right' as const, header: 'Requests', key: 'requests' },
{ align: 'right' as const, header: ['Cost', '(USD)'], key: 'cost' },
];
// Totals
let sumInput = 0;
let sumOutput = 0;
let sumTotal = 0;
let sumRequests = 0;
let sumCost = 0;
const rows: BoxTableRow[] = activeLogs.map((log: any) => {
const records: any[] = log.records || [];
// Aggregate tokens
let inputTokens = 0;
let outputTokens = 0;
for (const r of records) {
inputTokens += r.totalInputTokens || 0;
outputTokens += r.totalOutputTokens || 0;
}
const totalTokens = log.totalTokens || inputTokens + outputTokens;
const cost = log.totalSpend || 0;
const requests = log.totalRequests || 0;
sumInput += inputTokens;
sumOutput += outputTokens;
sumTotal += totalTokens;
sumRequests += requests;
sumCost += cost;
// Unique models
const modelSet = new Set<string>();
for (const r of records) {
if (r.model) modelSet.add(r.model);
}
const modelList = [...modelSet].sort().map((m) => `- ${m}`);
return {
cost: formatCost(cost),
date: log.day || '',
input: formatNumber(inputTokens),
models: modelList.length > 0 ? modelList : ['-'],
output: formatNumber(outputTokens),
requests: formatNumber(requests),
total: formatNumber(totalTokens),
};
});
// Total row
rows.push({
cost: pc.bold(formatCost(sumCost)),
date: pc.bold('Total'),
input: pc.bold(formatNumber(sumInput)),
models: '',
output: pc.bold(formatNumber(sumOutput)),
requests: pc.bold(formatNumber(sumRequests)),
total: pc.bold(formatNumber(sumTotal)),
});
const monthLabel = options.month || new Date().toISOString().slice(0, 7);
const mode = options.daily ? 'Daily' : 'Monthly';
printBoxTable(columns, rows, `LobeHub Token Usage Report - ${mode} (${monthLabel})`);
// Calendar heatmap - fetch past 12 months
const now = new Date();
const rangeStart = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate() + 1);
let yearLogs: any[];
try {
// Try single-request endpoint first
yearLogs = await client.usage.findAndGroupByDateRange.query({
endAt: now.toISOString().slice(0, 10),
startAt: rangeStart.toISOString().slice(0, 10),
});
} catch {
// Fallback: fetch each month concurrently
const monthKeys: string[] = [];
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
monthKeys.push(d.toISOString().slice(0, 7));
}
const results = await Promise.all(
monthKeys.map((mo) => client.usage.findAndGroupByDay.query({ mo })),
);
yearLogs = results.flat();
}
const calendarData = (Array.isArray(yearLogs) ? yearLogs : [])
.filter((log: any) => log.day)
.map((log: any) => ({
day: log.day,
value: log.totalTokens || 0,
}));
const yearTotal = calendarData.reduce((acc: number, d: any) => acc + d.value, 0);
printCalendarHeatmap(calendarData, {
label: `Past 12 months: ${formatNumber(yearTotal)} tokens`,
title: 'Activity (past 12 months)',
});
});
}

View file

@ -1,3 +1,5 @@
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
@ -20,12 +22,15 @@ import { registerSkillCommand } from './commands/skill';
import { registerStatusCommand } from './commands/status';
import { registerTopicCommand } from './commands/topic';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
const program = new Command();
program
.name('lh')
.description('LobeHub CLI - manage and connect to LobeHub services')
.version('0.1.0');
.version(version);
registerLoginCommand(program);
registerLogoutCommand(program);

View file

@ -24,7 +24,7 @@ vi.mock('../utils/logger', () => ({
},
}));
describe('file tools', () => {
describe('file tools (integration wrapper)', () => {
const tmpDir = path.join(os.tmpdir(), 'cli-file-test-' + process.pid);
beforeEach(async () => {
@ -35,424 +35,71 @@ describe('file tools', () => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
describe('readLocalFile', () => {
it('should read a file with default line range (0-200)', async () => {
const filePath = path.join(tmpDir, 'test.txt');
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
it('should re-export readLocalFile from shared package', async () => {
const filePath = path.join(tmpDir, 'test.txt');
await writeFile(filePath, 'hello world');
const result = await readLocalFile({ path: filePath });
const result = await readLocalFile({ path: filePath });
expect(result.lineCount).toBe(200);
expect(result.totalLineCount).toBe(300);
expect(result.loc).toEqual([0, 200]);
expect(result.filename).toBe('test.txt');
expect(result.fileType).toBe('txt');
});
it('should read full content when fullContent is true', async () => {
const filePath = path.join(tmpDir, 'full.txt');
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ fullContent: true, path: filePath });
expect(result.lineCount).toBe(300);
expect(result.loc).toEqual([0, 300]);
});
it('should read specific line range', async () => {
const filePath = path.join(tmpDir, 'range.txt');
const lines = Array.from({ length: 10 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ loc: [2, 5], path: filePath });
expect(result.lineCount).toBe(3);
expect(result.content).toBe('line 2\nline 3\nline 4');
expect(result.loc).toEqual([2, 5]);
});
it('should handle non-existent file', async () => {
const result = await readLocalFile({ path: path.join(tmpDir, 'nope.txt') });
expect(result.content).toContain('Error');
expect(result.lineCount).toBe(0);
expect(result.totalLineCount).toBe(0);
});
it('should detect file type from extension', async () => {
const filePath = path.join(tmpDir, 'code.ts');
await writeFile(filePath, 'const x = 1;');
const result = await readLocalFile({ path: filePath });
expect(result.fileType).toBe('ts');
});
it('should handle file without extension', async () => {
const filePath = path.join(tmpDir, 'Makefile');
await writeFile(filePath, 'all: build');
const result = await readLocalFile({ path: filePath });
expect(result.fileType).toBe('unknown');
});
expect(result.filename).toBe('test.txt');
expect(result.content).toBe('hello world');
});
describe('writeLocalFile', () => {
it('should write a file successfully', async () => {
const filePath = path.join(tmpDir, 'output.txt');
it('should re-export writeLocalFile from shared package', async () => {
const filePath = path.join(tmpDir, 'output.txt');
const result = await writeLocalFile({ content: 'hello world', path: filePath });
const result = await writeLocalFile({ content: 'written', path: filePath });
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hello world');
});
it('should create parent directories', async () => {
const filePath = path.join(tmpDir, 'sub', 'dir', 'file.txt');
const result = await writeLocalFile({ content: 'nested', path: filePath });
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('nested');
});
it('should return error for empty path', async () => {
const result = await writeLocalFile({ content: 'data', path: '' });
expect(result.success).toBe(false);
expect(result.error).toContain('Path cannot be empty');
});
it('should return error for undefined content', async () => {
const result = await writeLocalFile({
content: undefined as any,
path: path.join(tmpDir, 'f.txt'),
});
expect(result.success).toBe(false);
expect(result.error).toContain('Content cannot be empty');
});
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
});
describe('editLocalFile', () => {
it('should replace first occurrence by default', async () => {
const filePath = path.join(tmpDir, 'edit.txt');
await writeFile(filePath, 'hello world\nhello again');
it('should re-export editLocalFile from shared package', async () => {
const filePath = path.join(tmpDir, 'edit.txt');
await writeFile(filePath, 'hello world');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'hello',
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(1);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhello again');
expect(result.diffText).toBeDefined();
expect(result.linesAdded).toBeDefined();
expect(result.linesDeleted).toBeDefined();
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'hello',
});
it('should replace all occurrences when replace_all is true', async () => {
const filePath = path.join(tmpDir, 'edit-all.txt');
await writeFile(filePath, 'hello world\nhello again');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'hello',
replace_all: true,
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(2);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhi again');
});
it('should return error when old_string not found', async () => {
const filePath = path.join(tmpDir, 'no-match.txt');
await writeFile(filePath, 'hello world');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'xyz',
});
expect(result.success).toBe(false);
expect(result.replacements).toBe(0);
});
it('should handle special regex characters in old_string with replace_all', async () => {
const filePath = path.join(tmpDir, 'regex.txt');
await writeFile(filePath, 'price is $10.00 and $20.00');
const result = await editLocalFile({
file_path: filePath,
new_string: '$XX.XX',
old_string: '$10.00',
replace_all: true,
});
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('price is $XX.XX and $20.00');
});
it('should handle file read error', async () => {
const result = await editLocalFile({
file_path: path.join(tmpDir, 'nonexistent.txt'),
new_string: 'new',
old_string: 'old',
});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(1);
});
describe('listLocalFiles', () => {
it('should list files in directory', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
await mkdir(path.join(tmpDir, 'subdir'));
it('should re-export listLocalFiles from shared package', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
const result = await listLocalFiles({ path: tmpDir });
const result = await listLocalFiles({ path: tmpDir });
expect(result.totalCount).toBe(3);
expect(result.files.length).toBe(3);
const names = result.files.map((f: any) => f.name);
expect(names).toContain('a.txt');
expect(names).toContain('b.txt');
expect(names).toContain('subdir');
});
it('should sort by name ascending', async () => {
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'name',
sortOrder: 'asc',
});
expect(result.files[0].name).toBe('a.txt');
expect(result.files[2].name).toBe('c.txt');
});
it('should sort by size', async () => {
await writeFile(path.join(tmpDir, 'small.txt'), 'x');
await writeFile(path.join(tmpDir, 'large.txt'), 'x'.repeat(1000));
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'size',
sortOrder: 'asc',
});
expect(result.files[0].name).toBe('small.txt');
});
it('should sort by createdTime', async () => {
await writeFile(path.join(tmpDir, 'first.txt'), 'first');
// Small delay to ensure different timestamps
await new Promise((r) => setTimeout(r, 10));
await writeFile(path.join(tmpDir, 'second.txt'), 'second');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'createdTime',
sortOrder: 'asc',
});
expect(result.files.length).toBe(2);
});
it('should respect limit', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
const result = await listLocalFiles({ limit: 2, path: tmpDir });
expect(result.files.length).toBe(2);
expect(result.totalCount).toBe(3);
});
it('should handle non-existent directory', async () => {
const result = await listLocalFiles({ path: path.join(tmpDir, 'nope') });
expect(result.files).toEqual([]);
expect(result.totalCount).toBe(0);
});
it('should use default sortBy for unknown sort key', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'unknown' as any,
});
expect(result.files.length).toBe(1);
});
it('should mark directories correctly', async () => {
await mkdir(path.join(tmpDir, 'mydir'));
const result = await listLocalFiles({ path: tmpDir });
const dir = result.files.find((f: any) => f.name === 'mydir');
expect(dir.isDirectory).toBe(true);
expect(dir.type).toBe('directory');
});
expect(result.totalCount).toBeGreaterThan(0);
});
describe('globLocalFiles', () => {
it('should match glob patterns', async () => {
await writeFile(path.join(tmpDir, 'a.ts'), 'a');
await writeFile(path.join(tmpDir, 'b.ts'), 'b');
await writeFile(path.join(tmpDir, 'c.js'), 'c');
it('should re-export globLocalFiles from shared package', async () => {
await writeFile(path.join(tmpDir, 'a.ts'), 'a');
await writeFile(path.join(tmpDir, 'b.js'), 'b');
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.ts' });
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.ts' });
expect(result.files.length).toBe(2);
expect(result.files).toContain('a.ts');
expect(result.files).toContain('b.ts');
});
it('should ignore node_modules and .git', async () => {
await mkdir(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
await writeFile(path.join(tmpDir, 'node_modules', 'pkg', 'index.ts'), 'x');
await writeFile(path.join(tmpDir, 'src.ts'), 'y');
const result = await globLocalFiles({ cwd: tmpDir, pattern: '**/*.ts' });
expect(result.files).toEqual(['src.ts']);
});
it('should use process.cwd() when cwd not specified', async () => {
const result = await globLocalFiles({ pattern: '*.nonexistent-ext-xyz' });
expect(result.files).toEqual([]);
});
it('should handle invalid pattern gracefully', async () => {
// fast-glob handles most patterns; test with a simple one
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.txt' });
expect(result.files).toEqual([]);
});
expect(result.files).toContain('a.ts');
expect(result.files).not.toContain('b.js');
});
describe('editLocalFile edge cases', () => {
it('should count lines added and deleted', async () => {
const filePath = path.join(tmpDir, 'multiline.txt');
await writeFile(filePath, 'line1\nline2\nline3');
it('should re-export grepContent from shared package', async () => {
await writeFile(path.join(tmpDir, 'search.txt'), 'hello world');
const result = await editLocalFile({
file_path: filePath,
new_string: 'newA\nnewB\nnewC\nnewD',
old_string: 'line2',
});
const result = await grepContent({ cwd: tmpDir, pattern: 'hello' });
expect(result.success).toBe(true);
expect(result.linesAdded).toBeGreaterThan(0);
expect(result.linesDeleted).toBeGreaterThan(0);
});
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('matches');
});
describe('grepContent', () => {
it('should return matches using ripgrep', async () => {
await writeFile(path.join(tmpDir, 'search.txt'), 'hello world\nfoo bar\nhello again');
it('should re-export searchLocalFiles from shared package', async () => {
await writeFile(path.join(tmpDir, 'config.json'), '{}');
const result = await grepContent({ cwd: tmpDir, pattern: 'hello' });
const result = await searchLocalFiles({ directory: tmpDir, keywords: 'config' });
// Result depends on whether rg is installed
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('matches');
});
it('should support file pattern filter', async () => {
await writeFile(path.join(tmpDir, 'test.ts'), 'const x = 1;');
await writeFile(path.join(tmpDir, 'test.js'), 'const y = 2;');
const result = await grepContent({
cwd: tmpDir,
filePattern: '*.ts',
pattern: 'const',
});
expect(result).toHaveProperty('success');
});
it('should handle no matches', async () => {
await writeFile(path.join(tmpDir, 'empty.txt'), 'nothing here');
const result = await grepContent({ cwd: tmpDir, pattern: 'xyz_not_found' });
expect(result.matches).toEqual([]);
});
});
describe('searchLocalFiles', () => {
it('should find files by keyword', async () => {
await writeFile(path.join(tmpDir, 'config.json'), '{}');
await writeFile(path.join(tmpDir, 'config.yaml'), '');
await writeFile(path.join(tmpDir, 'readme.md'), '');
const result = await searchLocalFiles({ directory: tmpDir, keywords: 'config' });
expect(result.length).toBe(2);
expect(result.map((r: any) => r.name)).toContain('config.json');
});
it('should filter by content', async () => {
await writeFile(path.join(tmpDir, 'match.txt'), 'this has the secret');
await writeFile(path.join(tmpDir, 'nomatch.txt'), 'nothing here');
// Search with a broad pattern and content filter
const result = await searchLocalFiles({
contentContains: 'secret',
directory: tmpDir,
keywords: '',
});
// Content filtering should exclude files without 'secret'
expect(result.every((r: any) => r.name !== 'nomatch.txt' || false)).toBe(true);
});
it('should respect limit', async () => {
for (let i = 0; i < 5; i++) {
await writeFile(path.join(tmpDir, `file${i}.log`), `content ${i}`);
}
const result = await searchLocalFiles({
directory: tmpDir,
keywords: 'file',
limit: 2,
});
expect(result.length).toBe(2);
});
it('should use cwd when directory not specified', async () => {
const result = await searchLocalFiles({ keywords: 'nonexistent_xyz_file' });
expect(Array.isArray(result)).toBe(true);
});
it('should handle errors gracefully', async () => {
const result = await searchLocalFiles({
directory: '/nonexistent/path/xyz',
keywords: 'test',
});
expect(result).toEqual([]);
});
expect(result.length).toBe(1);
});
});

View file

@ -1,357 +1,9 @@
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { createPatch } from 'diff';
import fg from 'fast-glob';
import { log } from '../utils/logger';
// ─── readLocalFile ───
interface ReadFileParams {
fullContent?: boolean;
loc?: [number, number];
path: string;
}
export async function readLocalFile({ path: filePath, loc, fullContent }: ReadFileParams) {
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
log.debug(`Reading file: ${filePath}, loc=${JSON.stringify(effectiveLoc)}`);
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split('\n');
const totalLineCount = lines.length;
const totalCharCount = content.length;
let selectedContent: string;
let lineCount: number;
let actualLoc: [number, number];
if (effectiveLoc === undefined) {
selectedContent = content;
lineCount = totalLineCount;
actualLoc = [0, totalLineCount];
} else {
const [startLine, endLine] = effectiveLoc;
const selectedLines = lines.slice(startLine, endLine);
selectedContent = selectedLines.join('\n');
lineCount = selectedLines.length;
actualLoc = effectiveLoc;
}
const fileStat = await stat(filePath);
return {
charCount: selectedContent.length,
content: selectedContent,
createdTime: fileStat.birthtime,
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
filename: path.basename(filePath),
lineCount,
loc: actualLoc,
modifiedTime: fileStat.mtime,
totalCharCount,
totalLineCount,
};
} catch (error) {
const errorMessage = (error as Error).message;
return {
charCount: 0,
content: `Error accessing or processing file: ${errorMessage}`,
createdTime: new Date(),
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
filename: path.basename(filePath),
lineCount: 0,
loc: [0, 0] as [number, number],
modifiedTime: new Date(),
totalCharCount: 0,
totalLineCount: 0,
};
}
}
// ─── writeLocalFile ───
interface WriteFileParams {
content: string;
path: string;
}
export async function writeLocalFile({ path: filePath, content }: WriteFileParams) {
if (!filePath) return { error: 'Path cannot be empty', success: false };
if (content === undefined) return { error: 'Content cannot be empty', success: false };
try {
const dirname = path.dirname(filePath);
await mkdir(dirname, { recursive: true });
await writeFile(filePath, content, 'utf8');
log.debug(`File written: ${filePath} (${content.length} chars)`);
return { success: true };
} catch (error) {
return { error: `Failed to write file: ${(error as Error).message}`, success: false };
}
}
// ─── editLocalFile ───
interface EditFileParams {
file_path: string;
new_string: string;
old_string: string;
replace_all?: boolean;
}
export async function editLocalFile({
file_path: filePath,
old_string,
new_string,
replace_all = false,
}: EditFileParams) {
try {
const content = await readFile(filePath, 'utf8');
if (!content.includes(old_string)) {
return {
error: 'The specified old_string was not found in the file',
replacements: 0,
success: false,
};
}
let newContent: string;
let replacements: number;
if (replace_all) {
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
const matches = content.match(regex);
replacements = matches ? matches.length : 0;
newContent = content.replaceAll(old_string, new_string);
} else {
const index = content.indexOf(old_string);
if (index === -1) {
return { error: 'Old string not found', replacements: 0, success: false };
}
newContent = content.slice(0, index) + new_string + content.slice(index + old_string.length);
replacements = 1;
}
await writeFile(filePath, newContent, 'utf8');
const patch = createPatch(filePath, content, newContent, '', '');
const diffText = `diff --git a${filePath} b${filePath}\n${patch}`;
const patchLines = patch.split('\n');
let linesAdded = 0;
let linesDeleted = 0;
for (const line of patchLines) {
if (line.startsWith('+') && !line.startsWith('+++')) linesAdded++;
else if (line.startsWith('-') && !line.startsWith('---')) linesDeleted++;
}
return { diffText, linesAdded, linesDeleted, replacements, success: true };
} catch (error) {
return { error: (error as Error).message, replacements: 0, success: false };
}
}
// ─── listLocalFiles ───
interface ListFilesParams {
limit?: number;
path: string;
sortBy?: 'createdTime' | 'modifiedTime' | 'name' | 'size';
sortOrder?: 'asc' | 'desc';
}
export async function listLocalFiles({
path: dirPath,
sortBy = 'modifiedTime',
sortOrder = 'desc',
limit = 100,
}: ListFilesParams) {
try {
const entries = await readdir(dirPath);
const results: any[] = [];
for (const entry of entries) {
const fullPath = path.join(dirPath, entry);
try {
const stats = await stat(fullPath);
const isDirectory = stats.isDirectory();
results.push({
createdTime: stats.birthtime,
isDirectory,
lastAccessTime: stats.atime,
modifiedTime: stats.mtime,
name: entry,
path: fullPath,
size: stats.size,
type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
});
} catch {
// Skip files we can't stat
}
}
results.sort((a, b) => {
let comparison: number;
switch (sortBy) {
case 'name': {
comparison = (a.name || '').localeCompare(b.name || '');
break;
}
case 'modifiedTime': {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
break;
}
case 'createdTime': {
comparison = a.createdTime.getTime() - b.createdTime.getTime();
break;
}
case 'size': {
comparison = a.size - b.size;
break;
}
default: {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
}
}
return sortOrder === 'desc' ? -comparison : comparison;
});
const totalCount = results.length;
return { files: results.slice(0, limit), totalCount };
} catch (error) {
log.error(`Failed to list directory ${dirPath}:`, error);
return { files: [], totalCount: 0 };
}
}
// ─── globLocalFiles ───
interface GlobFilesParams {
cwd?: string;
pattern: string;
}
export async function globLocalFiles({ pattern, cwd }: GlobFilesParams) {
try {
const files = await fg(pattern, {
cwd: cwd || process.cwd(),
dot: false,
ignore: ['**/node_modules/**', '**/.git/**'],
});
return { files };
} catch (error) {
return { error: (error as Error).message, files: [] };
}
}
// ─── grepContent ───
interface GrepContentParams {
cwd?: string;
filePattern?: string;
pattern: string;
}
export async function grepContent({ pattern, cwd, filePattern }: GrepContentParams) {
const { spawn } = await import('node:child_process');
return new Promise<{ matches: any[]; success: boolean }>((resolve) => {
const args = ['--json', '-n'];
if (filePattern) args.push('--glob', filePattern);
args.push(pattern);
const child = spawn('rg', args, { cwd: cwd || process.cwd() });
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', () => {
// stderr consumed but not used
});
child.on('close', (code) => {
if (code !== 0 && code !== 1) {
// Fallback: use simple regex search
log.debug('rg not available, falling back to simple search');
resolve({ matches: [], success: false });
return;
}
try {
const matches = stdout
.split('\n')
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
resolve({ matches, success: true });
} catch {
resolve({ matches: [], success: true });
}
});
child.on('error', () => {
log.debug('rg not available');
resolve({ matches: [], success: false });
});
});
}
// ─── searchLocalFiles ───
interface SearchFilesParams {
contentContains?: string;
directory?: string;
keywords: string;
limit?: number;
}
export async function searchLocalFiles({
keywords,
directory,
contentContains,
limit = 30,
}: SearchFilesParams) {
try {
const cwd = directory || process.cwd();
const files = await fg(`**/*${keywords}*`, {
cwd,
dot: false,
ignore: ['**/node_modules/**', '**/.git/**'],
});
let results = files.map((f) => ({ name: path.basename(f), path: path.join(cwd, f) }));
if (contentContains) {
const filtered: typeof results = [];
for (const file of results) {
try {
const content = await readFile(file.path, 'utf8');
if (content.includes(contentContains)) {
filtered.push(file);
}
} catch {
// Skip unreadable files
}
}
results = filtered;
}
return results.slice(0, limit);
} catch (error) {
log.error('File search failed:', error);
return [];
}
}
export {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
} from '@lobechat/local-file-shell';

View file

@ -11,227 +11,55 @@ vi.mock('../utils/logger', () => ({
},
}));
describe('shell tools', () => {
describe('shell tools (integration wrapper)', () => {
afterEach(() => {
cleanupAllProcesses();
});
describe('runCommand', () => {
it('should execute a simple command', async () => {
const result = await runCommand({ command: 'echo hello' });
it('should delegate runCommand to shared package', async () => {
const result = await runCommand({ command: 'echo hello' });
expect(result.success).toBe(true);
expect(result.stdout).toContain('hello');
expect(result.exit_code).toBe(0);
});
it('should capture stderr', async () => {
const result = await runCommand({ command: 'echo error >&2' });
expect(result.stderr).toContain('error');
});
it('should handle command failure', async () => {
const result = await runCommand({ command: 'exit 1' });
expect(result.success).toBe(false);
expect(result.exit_code).toBe(1);
});
it('should handle command not found', async () => {
const result = await runCommand({ command: 'nonexistent_command_xyz_123' });
expect(result.success).toBe(false);
});
it('should timeout long-running commands', async () => {
const result = await runCommand({ command: 'sleep 10', timeout: 500 });
expect(result.success).toBe(false);
expect(result.error).toContain('timed out');
}, 10000);
it('should clamp timeout to minimum 1000ms', async () => {
const result = await runCommand({ command: 'echo fast', timeout: 100 });
expect(result.success).toBe(true);
});
it('should run command in background', async () => {
const result = await runCommand({
command: 'echo background',
run_in_background: true,
});
expect(result.success).toBe(true);
expect(result.shell_id).toBeDefined();
});
it('should strip ANSI codes from output', async () => {
const result = await runCommand({
command: 'printf "\\033[31mred\\033[0m"',
});
expect(result.output).not.toContain('\u001B');
});
it('should truncate very long output', async () => {
// Generate output longer than 80KB
const result = await runCommand({
command: `python3 -c "print('x' * 100000)" 2>/dev/null || printf '%0.sx' $(seq 1 100000)`,
});
// Output should be truncated
expect(result.output.length).toBeLessThanOrEqual(85000); // 80000 + truncation message
}, 15000);
it('should use description in log prefix', async () => {
const result = await runCommand({
command: 'echo test',
description: 'test command',
});
expect(result.success).toBe(true);
});
expect(result.success).toBe(true);
expect(result.stdout).toContain('hello');
});
describe('getCommandOutput', () => {
it('should get output from background process', async () => {
const bgResult = await runCommand({
command: 'echo hello && sleep 0.1',
run_in_background: true,
});
// Wait for output to be captured
await new Promise((r) => setTimeout(r, 200));
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
expect(output.success).toBe(true);
expect(output.stdout).toContain('hello');
it('should delegate background commands and getCommandOutput', async () => {
const bgResult = await runCommand({
command: 'echo background && sleep 0.1',
run_in_background: true,
});
it('should return error for unknown shell_id', async () => {
const result = await getCommandOutput({ shell_id: 'unknown-id' });
expect(bgResult.success).toBe(true);
expect(bgResult.shell_id).toBeDefined();
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
await new Promise((r) => setTimeout(r, 200));
it('should track running state', async () => {
const bgResult = await runCommand({
command: 'sleep 5',
run_in_background: true,
});
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
expect(output.running).toBe(true);
});
it('should support filter parameter', async () => {
const bgResult = await runCommand({
command: 'echo "line1\nline2\nline3"',
run_in_background: true,
});
await new Promise((r) => setTimeout(r, 200));
const output = await getCommandOutput({
filter: 'line2',
shell_id: bgResult.shell_id,
});
expect(output.success).toBe(true);
});
it('should handle invalid filter regex', async () => {
const bgResult = await runCommand({
command: 'echo test',
run_in_background: true,
});
await new Promise((r) => setTimeout(r, 200));
const output = await getCommandOutput({
filter: '[invalid',
shell_id: bgResult.shell_id,
});
expect(output.success).toBe(true);
});
it('should return new output only on subsequent calls', async () => {
const bgResult = await runCommand({
command: 'echo first && sleep 0.2 && echo second',
run_in_background: true,
});
await new Promise((r) => setTimeout(r, 100));
const first = await getCommandOutput({ shell_id: bgResult.shell_id });
await new Promise((r) => setTimeout(r, 300));
await getCommandOutput({ shell_id: bgResult.shell_id });
// First read should have "first"
expect(first.stdout).toContain('first');
});
const output = await getCommandOutput({ shell_id: bgResult.shell_id! });
expect(output.success).toBe(true);
expect(output.stdout).toContain('background');
});
describe('killCommand', () => {
it('should kill a background process', async () => {
const bgResult = await runCommand({
command: 'sleep 60',
run_in_background: true,
});
const result = await killCommand({ shell_id: bgResult.shell_id });
expect(result.success).toBe(true);
it('should delegate killCommand', async () => {
const bgResult = await runCommand({
command: 'sleep 60',
run_in_background: true,
});
it('should return error for unknown shell_id', async () => {
const result = await killCommand({ shell_id: 'unknown-id' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
const result = await killCommand({ shell_id: bgResult.shell_id! });
expect(result.success).toBe(true);
});
describe('killCommand error handling', () => {
it('should handle kill error on already-dead process', async () => {
const bgResult = await runCommand({
command: 'echo done',
run_in_background: true,
});
// Wait for process to finish
await new Promise((r) => setTimeout(r, 200));
// Process is already done, killing should still succeed or return error
const result = await killCommand({ shell_id: bgResult.shell_id });
// It may succeed (process already exited) or fail, but shouldn't throw
expect(result).toHaveProperty('success');
});
it('should return error for unknown shell_id', async () => {
const result = await getCommandOutput({ shell_id: 'unknown-id' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
describe('runCommand error handling', () => {
it('should handle spawn error for non-existent shell', async () => {
// Test with a command that causes spawn error
const result = await runCommand({ command: 'echo test' });
// Normal command should work
expect(result).toHaveProperty('success');
});
});
it('should cleanup all processes', async () => {
await runCommand({ command: 'sleep 60', run_in_background: true });
await runCommand({ command: 'sleep 60', run_in_background: true });
describe('cleanupAllProcesses', () => {
it('should kill all background processes', async () => {
await runCommand({ command: 'sleep 60', run_in_background: true });
await runCommand({ command: 'sleep 60', run_in_background: true });
cleanupAllProcesses();
// No processes should remain - subsequent getCommandOutput should fail
});
cleanupAllProcesses();
// No assertion needed — verifies no throw
});
});

View file

@ -1,233 +1,27 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import {
type GetCommandOutputParams,
type KillCommandParams,
runCommand as runCommandCore,
type RunCommandParams,
ShellProcessManager,
} from '@lobechat/local-file-shell';
import { log } from '../utils/logger';
// Maximum output length to prevent context explosion
const MAX_OUTPUT_LENGTH = 80_000;
const ANSI_REGEX =
// eslint-disable-next-line no-control-regex
/\u001B(?:[\u0040-\u005A\u005C-\u005F]|\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E])/g;
const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, '');
const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
const cleaned = stripAnsi(str);
if (cleaned.length <= maxLength) return cleaned;
return (
cleaned.slice(0, maxLength) +
'\n... [truncated, ' +
(cleaned.length - maxLength) +
' more characters]'
);
};
interface ShellProcess {
lastReadStderr: number;
lastReadStdout: number;
process: ChildProcess;
stderr: string[];
stdout: string[];
}
const shellProcesses = new Map<string, ShellProcess>();
const processManager = new ShellProcessManager();
export function cleanupAllProcesses() {
for (const [id, sp] of shellProcesses) {
try {
sp.process.kill();
} catch {
// Ignore
}
shellProcesses.delete(id);
}
processManager.cleanupAll();
}
// ─── runCommand ───
interface RunCommandParams {
command: string;
description?: string;
run_in_background?: boolean;
timeout?: number;
export async function runCommand(params: RunCommandParams) {
return runCommandCore(params, { logger: log, processManager });
}
export async function runCommand({
command,
description,
run_in_background,
timeout = 120_000,
}: RunCommandParams) {
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
log.debug(`${logPrefix} Starting`, { background: run_in_background, timeout });
const effectiveTimeout = Math.min(Math.max(timeout, 1000), 600_000);
const shellConfig =
process.platform === 'win32'
? { args: ['/c', command], cmd: 'cmd.exe' }
: { args: ['-c', command], cmd: '/bin/sh' };
try {
if (run_in_background) {
const shellId = randomUUID();
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
env: process.env,
shell: false,
});
const shellProcess: ShellProcess = {
lastReadStderr: 0,
lastReadStdout: 0,
process: childProcess,
stderr: [],
stdout: [],
};
childProcess.stdout?.on('data', (data) => {
shellProcess.stdout.push(data.toString());
});
childProcess.stderr?.on('data', (data) => {
shellProcess.stderr.push(data.toString());
});
childProcess.on('exit', (code) => {
log.debug(`${logPrefix} Background process exited`, { code, shellId });
});
shellProcesses.set(shellId, shellProcess);
log.debug(`${logPrefix} Started background`, { shellId });
return { shell_id: shellId, success: true };
} else {
return new Promise<any>((resolve) => {
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
env: process.env,
shell: false,
});
let stdout = '';
let stderr = '';
let killed = false;
const timeoutHandle = setTimeout(() => {
killed = true;
childProcess.kill();
resolve({
error: `Command timed out after ${effectiveTimeout}ms`,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
}, effectiveTimeout);
childProcess.stdout?.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('exit', (code) => {
if (!killed) {
clearTimeout(timeoutHandle);
const success = code === 0;
resolve({
exit_code: code || 0,
output: truncateOutput(stdout + stderr),
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success,
});
}
});
childProcess.on('error', (error) => {
clearTimeout(timeoutHandle);
resolve({
error: error.message,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
});
});
}
} catch (error) {
return { error: (error as Error).message, success: false };
}
export async function getCommandOutput(params: GetCommandOutputParams) {
return processManager.getOutput(params);
}
// ─── getCommandOutput ───
interface GetCommandOutputParams {
filter?: string;
shell_id: string;
}
export async function getCommandOutput({ shell_id, filter }: GetCommandOutputParams) {
const shellProcess = shellProcesses.get(shell_id);
if (!shellProcess) {
return {
error: `Shell ID ${shell_id} not found`,
output: '',
running: false,
stderr: '',
stdout: '',
success: false,
};
}
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
const newStdout = stdout.slice(lastReadStdout).join('');
const newStderr = stderr.slice(lastReadStderr).join('');
let output = newStdout + newStderr;
if (filter) {
try {
const regex = new RegExp(filter, 'gm');
const lines = output.split('\n');
output = lines.filter((line) => regex.test(line)).join('\n');
} catch {
// Invalid filter regex, use unfiltered output
}
}
shellProcess.lastReadStdout = stdout.length;
shellProcess.lastReadStderr = stderr.length;
const running = childProcess.exitCode === null;
return {
output: truncateOutput(output),
running,
stderr: truncateOutput(newStderr),
stdout: truncateOutput(newStdout),
success: true,
};
}
// ─── killCommand ───
interface KillCommandParams {
shell_id: string;
}
export async function killCommand({ shell_id }: KillCommandParams) {
const shellProcess = shellProcesses.get(shell_id);
if (!shellProcess) {
return { error: `Shell ID ${shell_id} not found`, success: false };
}
try {
shellProcess.process.kill();
shellProcesses.delete(shell_id);
return { success: true };
} catch (error) {
return { error: (error as Error).message, success: false };
}
export async function killCommand(params: KillCommandParams) {
return processManager.kill(params.shell_id);
}

View file

@ -0,0 +1,47 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`printBoxTable > should render a basic table 1`] = `
"┌───────┬───────┐
│ Name │ Count │
├───────┼───────┤
│ Alice │ 100 │
├───────┼───────┤
│ Bob │ 2,345 │
└───────┴───────┘"
`;
exports[`printBoxTable > should render a table with title and multi-line cells 1`] = `
"
╭─────────────────────────────────────────────────╮
│ Test Report │
╰─────────────────────────────────────────────────╯
┌────────────┬───────────────────┬────────┬───────┐
│ Date │ Models │ Total │ Cost │
│ │ │ Tokens │ (USD) │
├────────────┼───────────────────┼────────┼───────┤
│ 2026-03-01 │ - claude-opus-4-6 │ 19,134 │ $1.23 │
│ │ - gpt-4o │ │ │
├────────────┼───────────────────┼────────┼───────┤
│ 2026-03-02 │ - claude-opus-4-6 │ 5,678 │ $0.45 │
└────────────┴───────────────────┴────────┴───────┘"
`;
exports[`printBoxTable > should render the usage table format 1`] = `
"
╭──────────────────────────────────────────────────────────────────────────────────────────╮
│ LobeHub Token Usage Report - Monthly (2026-03) │
╰──────────────────────────────────────────────────────────────────────────────────────────╯
┌────────────┬────────────────────────┬───────────┬─────────┬───────────┬──────────┬───────┐
│ Date │ Models │ Input │ Output │ Total │ Requests │ Cost │
│ │ │ │ │ Tokens │ │ (USD) │
├────────────┼────────────────────────┼───────────┼─────────┼───────────┼──────────┼───────┤
│ 2026-03-01 │ - claude-opus-4-6 │ 4,190,339 │ 121,035 │ 4,311,374 │ 69 │ $3.56 │
│ │ - gemini-3-pro-preview │ │ │ │ │ │
├────────────┼────────────────────────┼───────────┼─────────┼───────────┼──────────┼───────┤
│ 2026-03-02 │ - claude-opus-4-6 │ 4,575,189 │ 34,885 │ 4,610,074 │ 62 │ $4.75 │
├────────────┼────────────────────────┼───────────┼─────────┼───────────┼──────────┼───────┤
│ Total │ │ 8,765,528 │ 155,920 │ 8,921,448 │ 131 │ $8.31 │
└────────────┴────────────────────────┴───────────┴─────────┴───────────┴──────────┴───────┘"
`;

View file

@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest';
import { formatCost, formatNumber, printBoxTable } from './format';
describe('formatNumber', () => {
it('should format numbers with commas', () => {
expect(formatNumber(0)).toBe('0');
expect(formatNumber(1234)).toBe('1,234');
expect(formatNumber(1_234_567)).toBe('1,234,567');
});
});
describe('formatCost', () => {
it('should format cost with dollar sign', () => {
expect(formatCost(0)).toBe('$0.00');
expect(formatCost(1.5)).toBe('$1.50');
expect(formatCost(123.456)).toBe('$123.46');
});
});
describe('printBoxTable', () => {
it('should render a basic table', () => {
const output: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...args: any[]) => {
output.push(args.join(' '));
});
const columns = [
{ align: 'left' as const, header: 'Name', key: 'name' },
{ align: 'right' as const, header: 'Count', key: 'count' },
];
const rows = [
{ count: '100', name: 'Alice' },
{ count: '2,345', name: 'Bob' },
];
printBoxTable(columns, rows);
expect(output.join('\n')).toMatchSnapshot();
vi.restoreAllMocks();
});
it('should render a table with title and multi-line cells', () => {
const output: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...args: any[]) => {
output.push(args.join(' '));
});
const columns = [
{ align: 'left' as const, header: 'Date', key: 'date' },
{ align: 'left' as const, header: 'Models', key: 'models' },
{ align: 'right' as const, header: ['Total', 'Tokens'], key: 'total' },
{ align: 'right' as const, header: ['Cost', '(USD)'], key: 'cost' },
];
const rows = [
{
cost: '$1.23',
date: '2026-03-01',
models: ['- claude-opus-4-6', '- gpt-4o'],
total: '19,134',
},
{
cost: '$0.45',
date: '2026-03-02',
models: ['- claude-opus-4-6'],
total: '5,678',
},
];
printBoxTable(columns, rows, 'Test Report');
expect(output.join('\n')).toMatchSnapshot();
vi.restoreAllMocks();
});
it('should render the usage table format', () => {
const output: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...args: any[]) => {
output.push(args.join(' '));
});
const columns = [
{ align: 'left' as const, header: 'Date', key: 'date' },
{ align: 'left' as const, header: 'Models', key: 'models' },
{ align: 'right' as const, header: 'Input', key: 'input' },
{ align: 'right' as const, header: 'Output', key: 'output' },
{ align: 'right' as const, header: ['Total', 'Tokens'], key: 'total' },
{ align: 'right' as const, header: 'Requests', key: 'requests' },
{ align: 'right' as const, header: ['Cost', '(USD)'], key: 'cost' },
];
const rows = [
{
cost: '$3.56',
date: '2026-03-01',
input: '4,190,339',
models: ['- claude-opus-4-6', '- gemini-3-pro-preview'],
output: '121,035',
requests: '69',
total: '4,311,374',
},
{
cost: '$4.75',
date: '2026-03-02',
input: '4,575,189',
models: ['- claude-opus-4-6'],
output: '34,885',
requests: '62',
total: '4,610,074',
},
{
cost: '$8.31',
date: 'Total',
input: '8,765,528',
models: '',
output: '155,920',
requests: '131',
total: '8,921,448',
},
];
printBoxTable(columns, rows, 'LobeHub Token Usage Report - Monthly (2026-03)');
expect(output.join('\n')).toMatchSnapshot();
vi.restoreAllMocks();
});
});

View file

@ -15,24 +15,222 @@ export function timeAgo(date: Date | string): string {
return `${seconds}s ago`;
}
export function truncate(str: string, len: number): string {
if (str.length <= len) return str;
return str.slice(0, len - 1) + '…';
export function truncate(str: string, maxWidth: number): string {
let width = 0;
let i = 0;
for (const char of str) {
const code = char.codePointAt(0)!;
const cw =
(code >= 0x1100 && code <= 0x115f) ||
(code >= 0x2e80 && code <= 0x303e) ||
(code >= 0x3040 && code <= 0x33bf) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0xa000 && code <= 0xa4cf) ||
(code >= 0xac00 && code <= 0xd7af) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0xfe30 && code <= 0xfe6f) ||
(code >= 0xff01 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6) ||
(code >= 0x20000 && code <= 0x2fa1f)
? 2
: 1;
if (width + cw > maxWidth - 1) {
return str.slice(0, i) + '…';
}
width += cw;
i += char.length;
}
return str;
}
export function printTable(rows: string[][], header: string[]) {
const allRows = [header, ...rows];
const colWidths = header.map((_, i) => Math.max(...allRows.map((r) => (r[i] || '').length)));
const colWidths = header.map((_, i) => Math.max(...allRows.map((r) => displayWidth(r[i] || ''))));
const headerLine = header.map((h, i) => h.padEnd(colWidths[i])).join(' ');
const headerLine = header.map((h, i) => padDisplay(h, colWidths[i])).join(' ');
console.log(pc.bold(headerLine));
for (const row of rows) {
const line = row.map((cell, i) => (cell || '').padEnd(colWidths[i])).join(' ');
const line = row.map((cell, i) => padDisplay(cell || '', colWidths[i])).join(' ');
console.log(line);
}
}
// ── Box-drawing table ─────────────────────────────────────
interface BoxTableColumn {
align?: 'left' | 'right';
header: string | string[];
key: string;
}
export interface BoxTableRow {
[key: string]: string | string[];
}
export function formatNumber(n: number): string {
return n.toLocaleString('en-US');
}
export function formatCost(n: number): string {
return `$${n.toFixed(2)}`;
}
// Strip ANSI escape codes for accurate width calculation
function stripAnsi(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replaceAll(/\x1B\[[0-9;]*m/g, '');
}
/**
* Calculate the display width of a string in the terminal.
* CJK characters and fullwidth symbols occupy 2 columns.
*/
function displayWidth(s: string): number {
const plain = stripAnsi(s);
let width = 0;
for (const char of plain) {
const code = char.codePointAt(0)!;
if (
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
(code >= 0x2e80 && code <= 0x303e) || // CJK Radicals, Kangxi, Symbols
(code >= 0x3040 && code <= 0x33bf) || // Hiragana, Katakana, Bopomofo, CJK Compat
(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
(code >= 0xa000 && code <= 0xa4cf) || // Yi Syllables/Radicals
(code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs
(code >= 0x20000 && code <= 0x2fa1f) // CJK Extension BF, Compatibility Supplement
) {
width += 2;
} else {
width += 1;
}
}
return width;
}
/**
* Pad a string to the target display width, accounting for CJK double-width characters.
*/
function padDisplay(s: string, targetWidth: number, align: 'left' | 'right' = 'left'): string {
const gap = targetWidth - displayWidth(s);
if (gap <= 0) return s;
return align === 'right' ? ' '.repeat(gap) + s : s + ' '.repeat(gap);
}
/**
* Render a bordered table with box-drawing characters, similar to ccusage output.
* Supports multi-line cells (string[]).
*/
export function printBoxTable(columns: BoxTableColumn[], rows: BoxTableRow[], title?: string) {
// Calculate the display height of each row (max lines across all cells)
const rowHeights = rows.map((row) => {
let maxLines = 1;
for (const col of columns) {
const val = row[col.key];
if (Array.isArray(val) && val.length > maxLines) maxLines = val.length;
}
return maxLines;
});
// Calculate column widths: max of header width and all cell widths
const colWidths = columns.map((col) => {
const headerLines = Array.isArray(col.header) ? col.header : [col.header];
let maxW = Math.max(...headerLines.map((h) => displayWidth(h)));
for (const row of rows) {
const val = row[col.key];
const lines = Array.isArray(val) ? val : [val || ''];
for (const line of lines) {
const w = displayWidth(line);
if (w > maxW) maxW = w;
}
}
return maxW;
});
// Box-drawing chars
const TL = '┌',
TR = '┐',
BL = '└',
BR = '┘';
const H = '─',
V = '│';
const TJ = '┬',
BJ = '┴',
LJ = '├',
RJ = '┤',
CJ = '┼';
const pad = (s: string, w: number, align: 'left' | 'right' = 'left') => {
return padDisplay(s, w, align);
};
const hLine = (left: string, mid: string, right: string) =>
left + colWidths.map((w) => H.repeat(w + 2)).join(mid) + right;
const renderRow = (cells: string[], align?: ('left' | 'right')[]) =>
V +
cells.map((c, i) => ' ' + pad(c, colWidths[i], align?.[i] || columns[i].align) + ' ').join(V) +
V;
// Title box
if (title) {
const totalWidth = colWidths.reduce((a, b) => a + b, 0) + (colWidths.length - 1) * 3 + 4;
const innerW = totalWidth - 4;
const titlePad = Math.max(0, innerW - displayWidth(title));
const leftPad = Math.floor(titlePad / 2);
const rightPad = titlePad - leftPad;
console.log();
console.log(' ╭' + '─'.repeat(innerW + 2) + '╮');
console.log(' │ ' + ' '.repeat(leftPad) + pc.bold(title) + ' '.repeat(rightPad) + ' │');
console.log(' ╰' + '─'.repeat(innerW + 2) + '╯');
console.log();
}
// Header
const headerHeight = Math.max(
...columns.map((c) => (Array.isArray(c.header) ? c.header.length : 1)),
);
console.log(hLine(TL, TJ, TR));
for (let line = 0; line < headerHeight; line++) {
const cells = columns.map((col) => {
const headerLines = Array.isArray(col.header) ? col.header : [col.header];
return headerLines[line] || '';
});
console.log(
renderRow(
cells,
columns.map(() => 'left'),
),
);
}
console.log(hLine(LJ, CJ, RJ));
// Data rows
rows.forEach((row, rowIdx) => {
const height = rowHeights[rowIdx];
for (let line = 0; line < height; line++) {
const cells = columns.map((col) => {
const val = row[col.key];
const lines = Array.isArray(val) ? val : [val || ''];
return lines[line] || '';
});
console.log(renderRow(cells));
}
if (rowIdx < rows.length - 1) {
console.log(hLine(LJ, CJ, RJ));
}
});
console.log(hLine(BL, BJ, BR));
}
export function pickFields(obj: Record<string, any>, fields: string[]): Record<string, any> {
const result: Record<string, any> = {};
for (const f of fields) {
@ -60,6 +258,135 @@ export function outputJson(data: unknown, fields?: string) {
}
}
// ── Calendar Heatmap ──────────────────────────────────────
interface CalendarDay {
day: string; // YYYY-MM-DD
value: number;
}
const HEATMAP_BLOCKS = [' ', '░', '▒', '▓', '█'];
const WEEKDAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
/**
* Render a GitHub-style calendar heatmap for usage data.
* Each column is a week, rows are weekdays (Mon-Sun).
*/
export function printCalendarHeatmap(
data: CalendarDay[],
options?: { label?: string; title?: string },
) {
if (data.length === 0) return;
// Build a value map
const valueMap = new Map<string, number>();
let maxVal = 0;
for (const d of data) {
valueMap.set(d.day, d.value);
if (d.value > maxVal) maxVal = d.value;
}
// Determine date range - pad to full weeks
const sorted = [...data].sort((a, b) => a.day.localeCompare(b.day));
const firstDate = new Date(sorted[0].day);
const lastDate = new Date(sorted.at(-1).day);
// Adjust to start on Monday
const startDay = firstDate.getDay(); // 0=Sun, 1=Mon, ...
const mondayOffset = startDay === 0 ? 6 : startDay - 1;
const start = new Date(firstDate);
start.setDate(start.getDate() - mondayOffset);
// Adjust to end on Sunday
const endDay = lastDate.getDay();
const sundayOffset = endDay === 0 ? 0 : 7 - endDay;
const end = new Date(lastDate);
end.setDate(end.getDate() + sundayOffset);
// Build grid: 7 rows (Mon-Sun) x N weeks
const weeks: string[][] = [];
const current = new Date(start);
let weekCol: string[] = [];
while (current <= end) {
const key = current.toISOString().slice(0, 10);
const val = valueMap.get(key) || 0;
// Quantize to block level
let level: number;
if (val === 0) {
level = 0;
} else if (maxVal > 0) {
level = Math.ceil((val / maxVal) * 4);
if (level < 1) level = 1;
if (level > 4) level = 4;
} else {
level = 0;
}
// Color the block
const block = HEATMAP_BLOCKS[level];
const colored = level > 0 ? pc.green(block) : pc.dim(block);
weekCol.push(colored);
if (weekCol.length === 7) {
weeks.push(weekCol);
weekCol = [];
}
current.setDate(current.getDate() + 1);
}
if (weekCol.length > 0) {
while (weekCol.length < 7) weekCol.push(' ');
weeks.push(weekCol);
}
// Print title
if (options?.title) {
console.log();
console.log(pc.bold(options.title));
}
// Print month labels on top, aligned with week columns
const monthLine: string[] = [];
let lastMonth = '';
for (let w = 0; w < weeks.length; w++) {
const weekStart = new Date(start);
weekStart.setDate(weekStart.getDate() + w * 7);
const monthStr = weekStart.toLocaleString('en-US', { month: 'short' });
if (monthStr !== lastMonth) {
monthLine.push(monthStr.padEnd(2));
lastMonth = monthStr;
} else {
monthLine.push(' ');
}
}
console.log(pc.dim(' ' + monthLine.join('')));
// Print each row (weekday)
for (let row = 0; row < 7; row++) {
const label = (WEEKDAY_LABELS[row] || '').padEnd(4);
const cells = weeks.map((week) => week[row] || ' ').join(' ');
console.log(pc.dim(label) + ' ' + cells);
}
// Legend
const legend =
' ' +
pc.dim('Less ') +
HEATMAP_BLOCKS.map((b, i) => (i === 0 ? pc.dim(b) : pc.green(b))).join(' ') +
pc.dim(' More');
console.log();
console.log(legend);
// Label
if (options?.label) {
console.log(pc.dim(` ${options.label}`));
}
console.log();
}
export function confirm(message: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stderr });
return new Promise((resolve) => {

View file

@ -14,6 +14,7 @@
"isolatedModules": true,
"paths": {
"@lobechat/device-gateway-client": ["../../packages/device-gateway-client/src"],
"@lobechat/local-file-shell": ["../../packages/local-file-shell/src"],
"@/*": ["../../src/*"]
}
},

View file

@ -4,8 +4,15 @@ export default defineConfig({
banner: { js: '#!/usr/bin/env node' },
clean: true,
entry: ['src/index.ts'],
external: ['@napi-rs/canvas', 'fast-glob', 'diff', 'debug'],
format: ['esm'],
noExternal: ['@lobechat/device-gateway-client', '@trpc/client', 'superjson'],
noExternal: [
'@lobechat/device-gateway-client',
'@lobechat/local-file-shell',
'@lobechat/file-loaders',
'@trpc/client',
'superjson',
],
platform: 'node',
target: 'node18',
});

View file

@ -9,6 +9,14 @@ export default defineConfig({
find: '@lobechat/device-gateway-client',
replacement: path.resolve(__dirname, '../../packages/device-gateway-client/src/index.ts'),
},
{
find: '@lobechat/local-file-shell',
replacement: path.resolve(__dirname, '../../packages/local-file-shell/src/index.ts'),
},
{
find: '@lobechat/file-loaders',
replacement: path.resolve(__dirname, '../../packages/file-loaders/src/index.ts'),
},
],
},
test: {

View file

@ -54,6 +54,7 @@
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/electron-server-ipc": "workspace:*",
"@lobechat/file-loaders": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobehub/i18n-cli": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.24.3",
"@t3-oss/env-core": "^0.13.8",

View file

@ -3,4 +3,5 @@ packages:
- '../../packages/electron-client-ipc'
- '../../packages/file-loaders'
- '../../packages/desktop-bridge'
- '../../packages/local-file-shell'
- '.'

View file

@ -1,5 +1,5 @@
import { constants } from 'node:fs';
import { access, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
@ -31,15 +31,20 @@ import {
type ShowSaveDialogResult,
type WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
import { loadFile, SYSTEM_FILES_TO_IGNORE } from '@lobechat/file-loaders';
import { createPatch } from 'diff';
import {
editLocalFile,
listLocalFiles,
moveLocalFiles,
readLocalFile,
renameLocalFile,
writeLocalFile,
} from '@lobechat/local-file-shell';
import { dialog, shell } from 'electron';
import { unzipSync } from 'fflate';
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
import ContentSearchService from '@/services/contentSearchSrv';
import FileSearchService from '@/services/fileSearchSrv';
import { makeSureDirExist } from '@/utils/file-system';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
@ -184,9 +189,8 @@ export default class LocalFileCtr extends ControllerModule {
const results: LocalReadFileResult[] = [];
for (const filePath of paths) {
// Initialize result object
logger.debug('Reading single file:', { filePath });
const result = await this.readFile({ path: filePath });
const result = await readLocalFile({ path: filePath });
results.push(result);
}
@ -195,284 +199,27 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async readFile({
path: filePath,
loc,
fullContent,
}: LocalReadFileParams): Promise<LocalReadFileResult> {
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
logger.debug('Starting to read file:', { filePath, fullContent, loc: effectiveLoc });
try {
const fileDocument = await loadFile(filePath);
const lines = fileDocument.content.split('\n');
const totalLineCount = lines.length;
const totalCharCount = fileDocument.content.length;
let content: string;
let charCount: number;
let lineCount: number;
let actualLoc: [number, number];
if (effectiveLoc === undefined) {
// Return full content
content = fileDocument.content;
charCount = totalCharCount;
lineCount = totalLineCount;
actualLoc = [0, totalLineCount];
} else {
// Return specified range
const [startLine, endLine] = effectiveLoc;
const selectedLines = lines.slice(startLine, endLine);
content = selectedLines.join('\n');
charCount = content.length;
lineCount = selectedLines.length;
actualLoc = effectiveLoc;
}
logger.debug('File read successfully:', {
filePath,
fullContent,
selectedLineCount: lineCount,
totalCharCount,
totalLineCount,
});
const result: LocalReadFileResult = {
// Char count for the selected range
charCount,
// Content for the selected range
content,
createdTime: fileDocument.createdTime,
fileType: fileDocument.fileType,
filename: fileDocument.filename,
lineCount,
loc: actualLoc,
// Line count for the selected range
modifiedTime: fileDocument.modifiedTime,
// Total char count of the file
totalCharCount,
// Total line count of the file
totalLineCount,
};
try {
const stats = await stat(filePath);
if (stats.isDirectory()) {
logger.warn('Attempted to read directory content:', { filePath });
result.content = 'This is a directory and cannot be read as plain text.';
result.charCount = 0;
result.lineCount = 0;
// Keep total counts for directory as 0 as well, or decide if they should reflect metadata size
result.totalCharCount = 0;
result.totalLineCount = 0;
}
} catch (statError) {
logger.error(`Failed to get file status ${filePath}:`, statError);
}
return result;
} catch (error) {
logger.error(`Failed to read file ${filePath}:`, error);
const errorMessage = (error as Error).message;
return {
charCount: 0,
content: `Error accessing or processing file: ${errorMessage}`,
createdTime: new Date(),
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
filename: path.basename(filePath),
lineCount: 0,
loc: [0, 0],
modifiedTime: new Date(),
totalCharCount: 0, // Add total counts to error result
totalLineCount: 0,
};
}
async readFile(params: LocalReadFileParams): Promise<LocalReadFileResult> {
logger.debug('Starting to read file:', {
filePath: params.path,
fullContent: params.fullContent,
loc: params.loc,
});
return readLocalFile(params);
}
@IpcMethod()
async listLocalFiles({
path: dirPath,
sortBy = 'modifiedTime',
sortOrder = 'desc',
limit = 100,
}: ListLocalFileParams): Promise<{ files: FileResult[]; totalCount: number }> {
logger.debug('Listing directory contents:', { dirPath, limit, sortBy, sortOrder });
const results: FileResult[] = [];
try {
const entries = await readdir(dirPath);
logger.debug('Directory entries retrieved successfully:', {
dirPath,
entriesCount: entries.length,
});
for (const entry of entries) {
// Skip specific system files based on the ignore list
if (SYSTEM_FILES_TO_IGNORE.includes(entry)) {
logger.debug('Ignoring system file:', { fileName: entry });
continue;
}
const fullPath = path.join(dirPath, entry);
try {
const stats = await stat(fullPath);
const isDirectory = stats.isDirectory();
results.push({
createdTime: stats.birthtime,
isDirectory,
lastAccessTime: stats.atime,
modifiedTime: stats.mtime,
name: entry,
path: fullPath,
size: stats.size,
type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
});
} catch (statError) {
// Silently ignore files we can't stat (e.g. permissions)
logger.error(`Failed to get file status ${fullPath}:`, statError);
}
}
// Sort entries based on sortBy and sortOrder
results.sort((a, b) => {
const comparison =
sortBy === 'name'
? (a.name || '').localeCompare(b.name || '')
: sortBy === 'createdTime'
? a.createdTime.getTime() - b.createdTime.getTime()
: sortBy === 'size'
? a.size - b.size
: a.modifiedTime.getTime() - b.modifiedTime.getTime();
return sortOrder === 'desc' ? -comparison : comparison;
});
const totalCount = results.length;
// Apply limit
const limitedResults = results.slice(0, limit);
logger.debug('Directory listing successful', {
dirPath,
resultCount: limitedResults.length,
totalCount,
});
return { files: limitedResults, totalCount };
} catch (error) {
logger.error(`Failed to list directory ${dirPath}:`, error);
// Rethrow or return an empty array/error object depending on desired behavior
// For now, returning empty result on error listing directory itself
return { files: [], totalCount: 0 };
}
async listLocalFiles(
params: ListLocalFileParams,
): Promise<{ files: FileResult[]; totalCount: number }> {
logger.debug('Listing directory contents:', params);
return listLocalFiles(params) as any;
}
@IpcMethod()
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
logger.debug('Starting batch file move:', { itemsCount: items?.length });
const results: LocalMoveFilesResultItem[] = [];
if (!items || items.length === 0) {
logger.warn('moveLocalFiles called with empty parameters');
return [];
}
// Process each move request
for (const item of items) {
const { oldPath: sourcePath, newPath } = item;
const logPrefix = `[Moving file ${sourcePath} -> ${newPath}]`;
logger.debug(`${logPrefix} Starting process`);
const resultItem: LocalMoveFilesResultItem = {
newPath: undefined,
sourcePath,
success: false,
};
// Basic validation
if (!sourcePath || !newPath) {
logger.error(`${logPrefix} Parameter validation failed: source or target path is empty`);
resultItem.error = 'Both oldPath and newPath are required for each item.';
results.push(resultItem);
continue;
}
try {
// Check if source exists
try {
await access(sourcePath, constants.F_OK);
logger.debug(`${logPrefix} Source file exists`);
} catch (accessError: any) {
if (accessError.code === 'ENOENT') {
logger.error(`${logPrefix} Source file does not exist`);
throw new Error(`Source path not found: ${sourcePath}`, { cause: accessError });
} else {
logger.error(`${logPrefix} Permission error accessing source file:`, accessError);
throw new Error(
`Permission denied accessing source path: ${sourcePath}. ${accessError.message}`,
{ cause: accessError },
);
}
}
// Check if target path is the same as source path
if (path.normalize(sourcePath) === path.normalize(newPath)) {
logger.info(`${logPrefix} Source and target paths are identical, skipping move`);
resultItem.success = true;
resultItem.newPath = newPath; // Report target path even if not moved
results.push(resultItem);
continue;
}
// LBYL: Ensure target directory exists
const targetDir = path.dirname(newPath);
makeSureDirExist(targetDir);
logger.debug(`${logPrefix} Ensured target directory exists: ${targetDir}`);
// Execute move (rename)
await rename(sourcePath, newPath);
resultItem.success = true;
resultItem.newPath = newPath;
logger.info(`${logPrefix} Move successful`);
} catch (error) {
logger.error(`${logPrefix} Move failed:`, error);
// Use similar error handling logic as handleMoveFile
let errorMessage = (error as Error).message;
if ((error as any).code === 'ENOENT')
errorMessage = `Source path not found: ${sourcePath}.`;
else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES')
errorMessage = `Permission denied to move the item at ${sourcePath}. Check file/folder permissions.`;
else if ((error as any).code === 'EBUSY')
errorMessage = `The file or directory at ${sourcePath} or ${newPath} is busy or locked by another process.`;
else if ((error as any).code === 'EXDEV')
errorMessage = `Cannot move across different file systems or drives. Source: ${sourcePath}, Target: ${newPath}.`;
else if ((error as any).code === 'EISDIR')
errorMessage = `Cannot overwrite a directory with a file, or vice versa. Source: ${sourcePath}, Target: ${newPath}.`;
else if ((error as any).code === 'ENOTEMPTY')
errorMessage = `The target directory ${newPath} is not empty (relevant on some systems if target exists and is a directory).`;
else if ((error as any).code === 'EEXIST')
errorMessage = `An item already exists at the target path: ${newPath}.`;
// Keep more specific errors from access or directory checks
else if (
!errorMessage.startsWith('Source path not found') &&
!errorMessage.startsWith('Permission denied accessing source path') &&
!errorMessage.includes('Target directory')
) {
// Keep the original error message if none of the specific codes match
}
resultItem.error = errorMessage;
}
results.push(resultItem);
}
logger.debug('Batch file move completed', {
successCount: results.filter((r) => r.success).length,
totalCount: results.length,
});
return results;
return moveLocalFiles({ items });
}
@IpcMethod()
@ -483,121 +230,14 @@ export default class LocalFileCtr extends ControllerModule {
newName: string;
path: string;
}): Promise<RenameLocalFileResult> {
const logPrefix = `[Renaming ${currentPath} -> ${newName}]`;
logger.debug(`${logPrefix} Starting rename request`);
// Basic validation (can also be done in frontend action)
if (!currentPath || !newName) {
logger.error(`${logPrefix} Parameter validation failed: path or new name is empty`);
return { error: 'Both path and newName are required.', newPath: '', success: false };
}
// Prevent path traversal or using invalid characters/names
if (
newName.includes('/') ||
newName.includes('\\') ||
newName === '.' ||
newName === '..' ||
/["*/:<>?\\|]/.test(newName) // Check for typical invalid filename characters
) {
logger.error(`${logPrefix} New filename contains illegal characters: ${newName}`);
return {
error:
'Invalid new name. It cannot contain path separators (/, \\), be "." or "..", or include characters like < > : " / \\ | ? *.',
newPath: '',
success: false,
};
}
let newPath: string;
try {
const dir = path.dirname(currentPath);
newPath = path.join(dir, newName);
logger.debug(`${logPrefix} Calculated new path: ${newPath}`);
// Check if paths are identical after calculation
if (path.normalize(currentPath) === path.normalize(newPath)) {
logger.info(
`${logPrefix} Source path and calculated target path are identical, skipping rename`,
);
// Consider success as no change is needed, but maybe inform the user?
// Return success for now.
return { newPath, success: true };
}
} catch (error) {
logger.error(`${logPrefix} Failed to calculate new path:`, error);
return {
error: `Internal error calculating the new path: ${(error as Error).message}`,
newPath: '',
success: false,
};
}
// Perform the rename operation using rename directly
try {
await rename(currentPath, newPath);
logger.info(`${logPrefix} Rename successful: ${currentPath} -> ${newPath}`);
// Optionally return the newPath if frontend needs it
// return { success: true, newPath: newPath };
return { newPath, success: true };
} catch (error) {
logger.error(`${logPrefix} Rename failed:`, error);
let errorMessage = (error as Error).message;
// Provide more specific error messages based on common codes
if ((error as any).code === 'ENOENT') {
errorMessage = `File or directory not found at the original path: ${currentPath}.`;
} else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES') {
errorMessage = `Permission denied to rename the item at ${currentPath}. Check file/folder permissions.`;
} else if ((error as any).code === 'EBUSY') {
errorMessage = `The file or directory at ${currentPath} or ${newPath} is busy or locked by another process.`;
} else if ((error as any).code === 'EISDIR' || (error as any).code === 'ENOTDIR') {
errorMessage = `Cannot rename - conflict between file and directory. Source: ${currentPath}, Target: ${newPath}.`;
} else if ((error as any).code === 'EEXIST') {
// Target already exists
errorMessage = `Cannot rename: an item with the name '${newName}' already exists at this location.`;
}
// Add more specific checks as needed
return { error: errorMessage, newPath: '', success: false };
}
logger.debug(`Renaming ${currentPath} -> ${newName}`);
return renameLocalFile({ newName, path: currentPath });
}
@IpcMethod()
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
const logPrefix = `[Writing file ${filePath}]`;
logger.debug(`${logPrefix} Starting to write file`, { contentLength: content?.length });
// Validate parameters
if (!filePath) {
logger.error(`${logPrefix} Parameter validation failed: path is empty`);
return { error: 'Path cannot be empty', success: false };
}
if (content === undefined) {
logger.error(`${logPrefix} Parameter validation failed: content is empty`);
return { error: 'Content cannot be empty', success: false };
}
try {
// Ensure target directory exists (use async to avoid blocking main thread)
const dirname = path.dirname(filePath);
logger.debug(`${logPrefix} Creating directory: ${dirname}`);
await mkdir(dirname, { recursive: true });
// Write file content
logger.debug(`${logPrefix} Starting to write content to file`);
await writeFile(filePath, content, 'utf8');
logger.info(`${logPrefix} File written successfully`, {
path: filePath,
size: content.length,
});
return { success: true };
} catch (error) {
logger.error(`${logPrefix} Failed to write file:`, error);
return {
error: `Failed to write file: ${(error as Error).message}`,
success: false,
};
}
logger.debug(`Writing file ${filePath}`, { contentLength: content?.length });
return writeLocalFile({ content, path: filePath });
}
@IpcMethod()
@ -746,92 +386,8 @@ export default class LocalFileCtr extends ControllerModule {
// ==================== File Editing ====================
@IpcMethod()
async handleEditFile({
file_path: filePath,
new_string,
old_string,
replace_all = false,
}: EditLocalFileParams): Promise<EditLocalFileResult> {
const logPrefix = `[editFile: ${filePath}]`;
logger.debug(`${logPrefix} Starting file edit`, { replace_all });
try {
// Read file content
const content = await readFile(filePath, 'utf8');
// Check if old_string exists
if (!content.includes(old_string)) {
logger.error(`${logPrefix} Old string not found in file`);
return {
error: 'The specified old_string was not found in the file',
replacements: 0,
success: false,
};
}
// Perform replacement
let newContent: string;
let replacements: number;
if (replace_all) {
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
const matches = content.match(regex);
replacements = matches ? matches.length : 0;
newContent = content.replaceAll(old_string, new_string);
} else {
// Replace only first occurrence
const index = content.indexOf(old_string);
if (index === -1) {
return {
error: 'Old string not found',
replacements: 0,
success: false,
};
}
newContent =
content.slice(0, index) + new_string + content.slice(index + old_string.length);
replacements = 1;
}
// Write back to file
await writeFile(filePath, newContent, 'utf8');
// Generate diff for UI display
const patch = createPatch(filePath, content, newContent, '', '');
const diffText = `diff --git a${filePath} b${filePath}\n${patch}`;
// Calculate lines added and deleted from patch
const patchLines = patch.split('\n');
let linesAdded = 0;
let linesDeleted = 0;
for (const line of patchLines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
linesAdded++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
linesDeleted++;
}
}
logger.info(`${logPrefix} File edited successfully`, {
linesAdded,
linesDeleted,
replacements,
});
return {
diffText,
linesAdded,
linesDeleted,
replacements,
success: true,
};
} catch (error) {
logger.error(`${logPrefix} Edit failed:`, error);
return {
error: (error as Error).message,
replacements: 0,
success: false,
};
}
async handleEditFile(params: EditLocalFileParams): Promise<EditLocalFileResult> {
logger.debug(`Editing file ${params.file_path}`, { replace_all: params.replace_all });
return editLocalFile(params);
}
}

View file

@ -1,7 +1,3 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type {
GetCommandOutputParams,
GetCommandOutputResult,
@ -10,6 +6,7 @@ import type {
RunCommandParams,
RunCommandResult,
} from '@lobechat/electron-client-ipc';
import { runCommand, ShellProcessManager } from '@lobechat/local-file-shell';
import { createLogger } from '@/utils/logger';
@ -17,256 +14,23 @@ import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:ShellCommandCtr');
// Maximum output length to prevent context explosion
const MAX_OUTPUT_LENGTH = 80_000;
/**
* Strip ANSI escape codes from terminal output
*/
// eslint-disable-next-line no-control-regex, regexp/no-obscure-range -- ANSI escape sequences use these ranges
const ANSI_REGEX = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, '');
/**
* Truncate string to max length with ellipsis indicator
*/
const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
const cleaned = stripAnsi(str);
if (cleaned.length <= maxLength) return cleaned;
return (
cleaned.slice(0, maxLength) +
'\n... [truncated, ' +
(cleaned.length - maxLength) +
' more characters]'
);
};
interface ShellProcess {
lastReadStderr: number;
lastReadStdout: number;
process: ChildProcess;
stderr: string[];
stdout: string[];
}
const processManager = new ShellProcessManager();
export default class ShellCommandCtr extends ControllerModule {
static override readonly groupName = 'shellCommand';
// Shell process management
private shellProcesses = new Map<string, ShellProcess>();
@IpcMethod()
async handleRunCommand({
command,
cwd,
description,
run_in_background,
timeout = 120_000,
}: RunCommandParams): Promise<RunCommandResult> {
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
logger.debug(`${logPrefix} Starting command execution`, {
background: run_in_background,
timeout,
});
// Validate timeout
const effectiveTimeout = Math.min(Math.max(timeout, 1000), 600_000);
// Cross-platform shell selection
const shellConfig =
process.platform === 'win32'
? { args: ['/c', command], cmd: 'cmd.exe' }
: { args: ['-c', command], cmd: '/bin/sh' };
try {
if (run_in_background) {
// Background execution
const shellId = randomUUID();
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
cwd,
env: process.env,
shell: false,
});
const shellProcess: ShellProcess = {
lastReadStderr: 0,
lastReadStdout: 0,
process: childProcess,
stderr: [],
stdout: [],
};
// Capture output
childProcess.stdout?.on('data', (data) => {
shellProcess.stdout.push(data.toString());
});
childProcess.stderr?.on('data', (data) => {
shellProcess.stderr.push(data.toString());
});
childProcess.on('exit', (code) => {
logger.debug(`${logPrefix} Background process exited`, { code, shellId });
});
this.shellProcesses.set(shellId, shellProcess);
logger.info(`${logPrefix} Started background execution`, { shellId });
return {
shell_id: shellId,
success: true,
};
} else {
// Synchronous execution with timeout
return new Promise((resolve) => {
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
cwd,
env: process.env,
shell: false,
});
let stdout = '';
let stderr = '';
let killed = false;
const timeoutHandle = setTimeout(() => {
killed = true;
childProcess.kill();
resolve({
error: `Command timed out after ${effectiveTimeout}ms`,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
}, effectiveTimeout);
childProcess.stdout?.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('exit', (code) => {
if (!killed) {
clearTimeout(timeoutHandle);
const success = code === 0;
logger.info(`${logPrefix} Command completed`, { code, success });
resolve({
exit_code: code || 0,
output: truncateOutput(stdout + stderr),
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success,
});
}
});
childProcess.on('error', (error) => {
clearTimeout(timeoutHandle);
logger.error(`${logPrefix} Command failed:`, error);
resolve({
error: error.message,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
});
});
}
} catch (error) {
logger.error(`${logPrefix} Failed to execute command:`, error);
return {
error: (error as Error).message,
success: false,
};
}
async handleRunCommand(params: RunCommandParams): Promise<RunCommandResult> {
return runCommand(params, { logger, processManager });
}
@IpcMethod()
async handleGetCommandOutput({
filter,
shell_id,
}: GetCommandOutputParams): Promise<GetCommandOutputResult> {
const logPrefix = `[getCommandOutput: ${shell_id}]`;
logger.debug(`${logPrefix} Retrieving output`);
const shellProcess = this.shellProcesses.get(shell_id);
if (!shellProcess) {
logger.error(`${logPrefix} Shell process not found`);
return {
error: `Shell ID ${shell_id} not found`,
output: '',
running: false,
stderr: '',
stdout: '',
success: false,
};
}
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
// Get new output since last read
const newStdout = stdout.slice(lastReadStdout).join('');
const newStderr = stderr.slice(lastReadStderr).join('');
let output = newStdout + newStderr;
// Apply filter if provided
if (filter) {
try {
const regex = new RegExp(filter, 'gm');
const lines = output.split('\n');
output = lines.filter((line) => regex.test(line)).join('\n');
} catch (error) {
logger.error(`${logPrefix} Invalid filter regex:`, error);
}
}
// Update last read positions separately
shellProcess.lastReadStdout = stdout.length;
shellProcess.lastReadStderr = stderr.length;
const running = childProcess.exitCode === null;
logger.debug(`${logPrefix} Output retrieved`, {
outputLength: output.length,
running,
});
return {
output: truncateOutput(output),
running,
stderr: truncateOutput(newStderr),
stdout: truncateOutput(newStdout),
success: true,
};
async handleGetCommandOutput(params: GetCommandOutputParams): Promise<GetCommandOutputResult> {
return processManager.getOutput(params);
}
@IpcMethod()
async handleKillCommand({ shell_id }: KillCommandParams): Promise<KillCommandResult> {
const logPrefix = `[killCommand: ${shell_id}]`;
logger.debug(`${logPrefix} Attempting to kill shell`);
const shellProcess = this.shellProcesses.get(shell_id);
if (!shellProcess) {
logger.error(`${logPrefix} Shell process not found`);
return {
error: `Shell ID ${shell_id} not found`,
success: false,
};
}
try {
shellProcess.process.kill();
this.shellProcesses.delete(shell_id);
logger.info(`${logPrefix} Shell killed successfully`);
return { success: true };
} catch (error) {
logger.error(`${logPrefix} Failed to kill shell:`, error);
return {
error: (error as Error).message,
success: false,
};
}
return processManager.kill(shell_id);
}
}

View file

@ -14,7 +14,6 @@ vi.mock('electron', () => ({
},
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
@ -24,609 +23,107 @@ vi.mock('@/utils/logger', () => ({
}),
}));
// Mock child_process
// Mock child_process for the shared package
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
}));
// Mock crypto
vi.mock('node:crypto', () => ({
randomUUID: vi.fn(() => 'test-uuid-123'),
}));
const mockApp = {} as unknown as App;
describe('ShellCommandCtr', () => {
let shellCommandCtr: ShellCommandCtr;
describe('ShellCommandCtr (thin wrapper)', () => {
let ctr: ShellCommandCtr;
let mockSpawn: any;
let mockChildProcess: any;
beforeEach(async () => {
vi.clearAllMocks();
// Import mocks
const childProcessModule = await import('node:child_process');
mockSpawn = vi.mocked(childProcessModule.spawn);
// Create mock child process
mockChildProcess = {
stdout: {
on: vi.fn(),
},
stderr: {
on: vi.fn(),
},
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
kill: vi.fn(),
exitCode: null,
};
mockSpawn.mockReturnValue(mockChildProcess);
shellCommandCtr = new ShellCommandCtr(mockApp);
ctr = new ShellCommandCtr(mockApp);
});
describe('handleRunCommand', () => {
describe('synchronous mode', () => {
it('should execute command successfully', async () => {
let exitCallback: (code: number) => void;
let stdoutCallback: (data: Buffer) => void;
it('should delegate handleRunCommand to shared runCommand', async () => {
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') setTimeout(() => callback(0), 10);
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') setTimeout(() => callback(Buffer.from('output\n')), 5);
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') {
exitCallback = callback;
// Simulate successful exit
setTimeout(() => exitCallback(0), 10);
}
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stdoutCallback = callback;
// Simulate output
setTimeout(() => stdoutCallback(Buffer.from('test output\n')), 5);
}
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
const result = await shellCommandCtr.handleRunCommand({
command: 'echo "test"',
description: 'test command',
});
expect(result.success).toBe(true);
expect(result.stdout).toBe('test output\n');
expect(result.exit_code).toBe(0);
});
it('should handle command timeout', async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
const result = await shellCommandCtr.handleRunCommand({
command: 'sleep 10',
description: 'long running command',
timeout: 100,
});
expect(result.success).toBe(false);
expect(result.error).toContain('timed out');
expect(mockChildProcess.kill).toHaveBeenCalled();
});
it('should handle command execution error', async () => {
let errorCallback: (error: Error) => void;
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'error') {
errorCallback = callback;
setTimeout(() => errorCallback(new Error('Command not found')), 10);
}
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
const result = await shellCommandCtr.handleRunCommand({
command: 'invalid-command',
description: 'invalid command',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Command not found');
});
it('should handle non-zero exit code', async () => {
let exitCallback: (code: number) => void;
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') {
exitCallback = callback;
setTimeout(() => exitCallback(1), 10);
}
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
const result = await shellCommandCtr.handleRunCommand({
command: 'exit 1',
description: 'failing command',
});
expect(result.success).toBe(false);
expect(result.exit_code).toBe(1);
});
it('should capture stderr output', async () => {
let exitCallback: (code: number) => void;
let stderrCallback: (data: Buffer) => void;
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') {
exitCallback = callback;
setTimeout(() => exitCallback(1), 10);
}
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stderrCallback = callback;
setTimeout(() => stderrCallback(Buffer.from('error message\n')), 5);
}
return mockChildProcess.stderr;
});
const result = await shellCommandCtr.handleRunCommand({
command: 'command-with-error',
description: 'command with stderr',
});
expect(result.stderr).toBe('error message\n');
});
it('should strip ANSI escape codes from output', async () => {
let exitCallback: (code: number) => void;
let stdoutCallback: (data: Buffer) => void;
let stderrCallback: (data: Buffer) => void;
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') {
exitCallback = callback;
setTimeout(() => exitCallback(0), 10);
}
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stdoutCallback = callback;
// Simulate output with ANSI color codes
setTimeout(
() =>
stdoutCallback(
Buffer.from(
'\x1B[38;5;250m███████╗\x1B[0m\n\x1B[1;32mSuccess\x1B[0m\n\x1B[31mError\x1B[0m',
),
),
5,
);
}
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stderrCallback = callback;
setTimeout(
() => stderrCallback(Buffer.from('\x1B[33mwarning:\x1B[0m something happened')),
5,
);
}
return mockChildProcess.stderr;
});
const result = await shellCommandCtr.handleRunCommand({
command: 'npx skills find react',
description: 'search skills',
});
expect(result.success).toBe(true);
// ANSI codes should be stripped
expect(result.stdout).not.toContain('\x1B[');
expect(result.stdout).toContain('███████╗');
expect(result.stdout).toContain('Success');
expect(result.stdout).toContain('Error');
expect(result.stderr).not.toContain('\x1B[');
expect(result.stderr).toContain('warning: something happened');
});
it('should truncate long output to prevent context explosion', async () => {
let exitCallback: (code: number) => void;
let stdoutCallback: (data: Buffer) => void;
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') {
exitCallback = callback;
setTimeout(() => exitCallback(0), 10);
}
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stdoutCallback = callback;
// Simulate very long output (100k characters, exceeding 80k MAX_OUTPUT_LENGTH)
const longOutput = 'x'.repeat(100_000);
setTimeout(() => stdoutCallback(Buffer.from(longOutput)), 5);
}
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
const result = await shellCommandCtr.handleRunCommand({
command: 'command-with-long-output',
description: 'long output command',
});
expect(result.success).toBe(true);
// Output should be truncated to 80k + truncation message
expect(result.stdout!.length).toBeLessThan(100_000);
expect(result.stdout).toContain('truncated');
expect(result.stdout).toContain('more characters');
});
it('should enforce timeout limits', async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
// Test minimum timeout
const minResult = await shellCommandCtr.handleRunCommand({
command: 'sleep 5',
timeout: 500, // Below 1000ms minimum
});
expect(minResult.success).toBe(false);
expect(minResult.error).toContain('1000ms'); // Should use 1000ms minimum
});
const result = await ctr.handleRunCommand({
command: 'echo test',
description: 'test',
});
describe('background mode', () => {
it('should start command in background', async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
const result = await shellCommandCtr.handleRunCommand({
command: 'long-running-task',
description: 'background task',
run_in_background: true,
});
expect(result.success).toBe(true);
expect(result.shell_id).toBe('test-uuid-123');
});
it('should use correct shell on Windows', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
await shellCommandCtr.handleRunCommand({
command: 'dir',
description: 'windows command',
run_in_background: true,
});
expect(mockSpawn).toHaveBeenCalledWith('cmd.exe', ['/c', 'dir'], expect.any(Object));
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should use correct shell on Unix', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
await shellCommandCtr.handleRunCommand({
command: 'ls',
description: 'unix command',
run_in_background: true,
});
expect(mockSpawn).toHaveBeenCalledWith('/bin/sh', ['-c', 'ls'], expect.any(Object));
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should pass cwd to spawn options when provided', async () => {
let exitCallback: (code: number) => void;
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') {
exitCallback = callback;
setTimeout(() => exitCallback(0), 10);
}
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
await shellCommandCtr.handleRunCommand({
command: 'pwd',
cwd: '/tmp/skill-runtime',
description: 'run from cwd',
});
expect(mockSpawn).toHaveBeenCalledWith(
'/bin/sh',
['-c', 'pwd'],
expect.objectContaining({
cwd: '/tmp/skill-runtime',
env: process.env,
shell: false,
}),
);
});
});
expect(result.success).toBe(true);
expect(result.stdout).toContain('output');
});
describe('handleGetCommandOutput', () => {
beforeEach(async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
// Simulate some output
setTimeout(() => callback(Buffer.from('line 1\n')), 5);
setTimeout(() => callback(Buffer.from('line 2\n')), 10);
}
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
setTimeout(() => callback(Buffer.from('error line\n')), 7);
}
return mockChildProcess.stderr;
});
it('should delegate handleGetCommandOutput to processManager', async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') setTimeout(() => callback(Buffer.from('bg output\n')), 5);
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
// Start a background process first
await shellCommandCtr.handleRunCommand({
command: 'test-command',
run_in_background: true,
});
await ctr.handleRunCommand({
command: 'test',
run_in_background: true,
});
it('should retrieve command output', async () => {
// Wait for output to be captured
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((r) => setTimeout(r, 20));
const result = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(result.success).toBe(true);
expect(result.stdout).toContain('line 1');
expect(result.stderr).toContain('error line');
const result = await ctr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
it('should return error for non-existent shell_id', async () => {
const result = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'non-existent-id',
});
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('should filter output with regex', async () => {
// Wait for output to be captured
await new Promise((resolve) => setTimeout(resolve, 20));
const result = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
filter: 'line 1',
});
expect(result.success).toBe(true);
expect(result.output).toContain('line 1');
expect(result.output).not.toContain('line 2');
});
it('should only return new output since last read', async () => {
// Wait for initial output
await new Promise((resolve) => setTimeout(resolve, 20));
// First read
const firstResult = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(firstResult.stdout).toContain('line 1');
// Second read should return empty (no new output)
const secondResult = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(secondResult.stdout).toBe('');
expect(secondResult.stderr).toBe('');
});
it('should handle invalid regex filter gracefully', async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
const result = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
filter: '[invalid(regex',
});
expect(result.success).toBe(true);
// Should return unfiltered output when filter is invalid
});
it('should report running status correctly', async () => {
mockChildProcess.exitCode = null;
const runningResult = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(runningResult.running).toBe(true);
// Simulate process exit
mockChildProcess.exitCode = 0;
const exitedResult = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(exitedResult.running).toBe(false);
});
it('should track stdout and stderr offsets separately when streaming output', async () => {
// Create a new background process with manual control over stdout/stderr
let stdoutCallback: (data: Buffer) => void;
let stderrCallback: (data: Buffer) => void;
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stdoutCallback = callback;
}
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') {
stderrCallback = callback;
}
return mockChildProcess.stderr;
});
// Start a new background process
await shellCommandCtr.handleRunCommand({
command: 'test-interleaved',
run_in_background: true,
});
// Simulate stderr output first
stderrCallback(Buffer.from('error 1\n'));
await new Promise((resolve) => setTimeout(resolve, 5));
// First read - should get stderr
const firstRead = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(firstRead.stderr).toBe('error 1\n');
expect(firstRead.stdout).toBe('');
// Simulate stdout output after stderr
stdoutCallback(Buffer.from('output 1\n'));
await new Promise((resolve) => setTimeout(resolve, 5));
// Second read - should get stdout without losing data
const secondRead = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(secondRead.stdout).toBe('output 1\n');
expect(secondRead.stderr).toBe('');
// Simulate more stderr
stderrCallback(Buffer.from('error 2\n'));
await new Promise((resolve) => setTimeout(resolve, 5));
// Third read - should get new stderr
const thirdRead = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(thirdRead.stderr).toBe('error 2\n');
expect(thirdRead.stdout).toBe('');
// Simulate more stdout
stdoutCallback(Buffer.from('output 2\n'));
await new Promise((resolve) => setTimeout(resolve, 5));
// Fourth read - should get new stdout
const fourthRead = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(fourthRead.stdout).toBe('output 2\n');
expect(fourthRead.stderr).toBe('');
});
expect(result.success).toBe(true);
expect(result.stdout).toContain('bg output');
});
describe('handleKillCommand', () => {
beforeEach(async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
it('should delegate handleKillCommand to processManager', async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
// Start a background process
await shellCommandCtr.handleRunCommand({
command: 'test-command',
run_in_background: true,
});
await ctr.handleRunCommand({
command: 'test',
run_in_background: true,
});
it('should kill command successfully', async () => {
const result = await shellCommandCtr.handleKillCommand({
shell_id: 'test-uuid-123',
});
expect(result.success).toBe(true);
expect(mockChildProcess.kill).toHaveBeenCalled();
const result = await ctr.handleKillCommand({
shell_id: 'test-uuid-123',
});
it('should return error for non-existent shell_id', async () => {
const result = await shellCommandCtr.handleKillCommand({
shell_id: 'non-existent-id',
});
expect(result.success).toBe(true);
expect(mockChildProcess.kill).toHaveBeenCalled();
});
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
it('should return error for non-existent shell_id', async () => {
const result = await ctr.handleGetCommandOutput({
shell_id: 'non-existent',
});
it('should remove process from map after killing', async () => {
await shellCommandCtr.handleKillCommand({
shell_id: 'test-uuid-123',
});
// Try to get output from killed process
const outputResult = await shellCommandCtr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
});
expect(outputResult.success).toBe(false);
expect(outputResult.error).toContain('not found');
});
it('should handle kill error gracefully', async () => {
mockChildProcess.kill.mockImplementation(() => {
throw new Error('Kill failed');
});
const result = await shellCommandCtr.handleKillCommand({
shell_id: 'test-uuid-123',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Kill failed');
});
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
});

View file

@ -6,6 +6,7 @@ export default defineConfig({
alias: {
'@': resolve(__dirname, './src/main'),
'~common': resolve(__dirname, './src/common'),
'@lobechat/local-file-shell': resolve(__dirname, '../../packages/local-file-shell/src'),
},
coverage: {
all: false,

View file

@ -231,6 +231,7 @@
"@lobechat/eval-rubric": "workspace:*",
"@lobechat/fetch-sse": "workspace:*",
"@lobechat/file-loaders": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobechat/memory-user-memory": "workspace:*",
"@lobechat/model-runtime": "workspace:*",
"@lobechat/observability-otel": "workspace:*",

View file

@ -0,0 +1,13 @@
{
"name": "@lobechat/local-file-shell",
"version": "1.0.0",
"private": true,
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"@lobechat/file-loaders": "workspace:*",
"diff": "^8.0.2",
"fast-glob": "^3.3.3"
}
}

View file

@ -0,0 +1,492 @@
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
moveLocalFiles,
readLocalFile,
renameLocalFile,
searchLocalFiles,
writeLocalFile,
} from '../index';
describe('file operations', () => {
const tmpDir = path.join(os.tmpdir(), 'local-file-shell-test-' + process.pid);
beforeEach(async () => {
await mkdir(tmpDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
// ─── readLocalFile ───
describe('readLocalFile', () => {
it('should read file with default line range (0-200)', async () => {
const filePath = path.join(tmpDir, 'test.txt');
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ path: filePath });
expect(result.lineCount).toBe(200);
expect(result.totalLineCount).toBe(300);
expect(result.loc).toEqual([0, 200]);
expect(result.filename).toBe('test.txt');
expect(result.fileType).toBe('txt');
});
it('should read full content when fullContent is true', async () => {
const filePath = path.join(tmpDir, 'full.txt');
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ fullContent: true, path: filePath });
expect(result.lineCount).toBe(300);
expect(result.loc).toEqual([0, 300]);
});
it('should read specific line range', async () => {
const filePath = path.join(tmpDir, 'range.txt');
const lines = Array.from({ length: 10 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ loc: [2, 5], path: filePath });
expect(result.lineCount).toBe(3);
expect(result.content).toBe('line 2\nline 3\nline 4');
expect(result.loc).toEqual([2, 5]);
});
it('should handle non-existent file', async () => {
const result = await readLocalFile({ path: path.join(tmpDir, 'nope.txt') });
expect(result.content).toContain('Error');
expect(result.lineCount).toBe(0);
expect(result.totalLineCount).toBe(0);
});
it('should detect file type from extension', async () => {
const filePath = path.join(tmpDir, 'code.ts');
await writeFile(filePath, 'const x = 1;');
const result = await readLocalFile({ path: filePath });
expect(result.fileType).toBe('ts');
});
it('should handle file without extension', async () => {
const filePath = path.join(tmpDir, 'Makefile');
await writeFile(filePath, 'all: build');
const result = await readLocalFile({ path: filePath });
expect(result.fileType).toBe('unknown');
});
it('should include timestamps', async () => {
const filePath = path.join(tmpDir, 'time.txt');
await writeFile(filePath, 'content');
const result = await readLocalFile({ path: filePath });
expect(result.createdTime).toBeInstanceOf(Date);
expect(result.modifiedTime).toBeInstanceOf(Date);
});
});
// ─── writeLocalFile ───
describe('writeLocalFile', () => {
it('should write file successfully', async () => {
const filePath = path.join(tmpDir, 'output.txt');
const result = await writeLocalFile({ content: 'hello world', path: filePath });
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hello world');
});
it('should create parent directories', async () => {
const filePath = path.join(tmpDir, 'sub', 'dir', 'file.txt');
const result = await writeLocalFile({ content: 'nested', path: filePath });
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('nested');
});
it('should return error for empty path', async () => {
const result = await writeLocalFile({ content: 'data', path: '' });
expect(result.success).toBe(false);
expect(result.error).toContain('Path cannot be empty');
});
it('should return error for undefined content', async () => {
const result = await writeLocalFile({
content: undefined as any,
path: path.join(tmpDir, 'f.txt'),
});
expect(result.success).toBe(false);
expect(result.error).toContain('Content cannot be empty');
});
});
// ─── editLocalFile ───
describe('editLocalFile', () => {
it('should replace first occurrence by default', async () => {
const filePath = path.join(tmpDir, 'edit.txt');
await writeFile(filePath, 'hello world\nhello again');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'hello',
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(1);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhello again');
expect(result.diffText).toBeDefined();
expect(result.linesAdded).toBeDefined();
expect(result.linesDeleted).toBeDefined();
});
it('should replace all occurrences when replace_all is true', async () => {
const filePath = path.join(tmpDir, 'edit-all.txt');
await writeFile(filePath, 'hello world\nhello again');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'hello',
replace_all: true,
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(2);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhi again');
});
it('should return error when old_string not found', async () => {
const filePath = path.join(tmpDir, 'no-match.txt');
await writeFile(filePath, 'hello world');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'xyz',
});
expect(result.success).toBe(false);
expect(result.replacements).toBe(0);
});
it('should handle special regex characters in old_string with replace_all', async () => {
const filePath = path.join(tmpDir, 'regex.txt');
await writeFile(filePath, 'price is $10.00 and $20.00');
const result = await editLocalFile({
file_path: filePath,
new_string: '$XX.XX',
old_string: '$10.00',
replace_all: true,
});
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('price is $XX.XX and $20.00');
});
it('should handle non-existent file', async () => {
const result = await editLocalFile({
file_path: path.join(tmpDir, 'nonexistent.txt'),
new_string: 'new',
old_string: 'old',
});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should count lines added and deleted', async () => {
const filePath = path.join(tmpDir, 'multiline.txt');
await writeFile(filePath, 'line1\nline2\nline3');
const result = await editLocalFile({
file_path: filePath,
new_string: 'newA\nnewB\nnewC\nnewD',
old_string: 'line2',
});
expect(result.success).toBe(true);
expect(result.linesAdded).toBeGreaterThan(0);
expect(result.linesDeleted).toBeGreaterThan(0);
});
});
// ─── listLocalFiles ───
describe('listLocalFiles', () => {
it('should list files in directory', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
await mkdir(path.join(tmpDir, 'subdir'));
const result = await listLocalFiles({ path: tmpDir });
expect(result.totalCount).toBe(3);
expect(result.files.length).toBe(3);
const names = result.files.map((f) => f.name);
expect(names).toContain('a.txt');
expect(names).toContain('b.txt');
expect(names).toContain('subdir');
});
it('should sort by name ascending', async () => {
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'name',
sortOrder: 'asc',
});
expect(result.files[0].name).toBe('a.txt');
expect(result.files[2].name).toBe('c.txt');
});
it('should sort by size', async () => {
await writeFile(path.join(tmpDir, 'small.txt'), 'x');
await writeFile(path.join(tmpDir, 'large.txt'), 'x'.repeat(1000));
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'size',
sortOrder: 'asc',
});
expect(result.files[0].name).toBe('small.txt');
});
it('should respect limit', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
const result = await listLocalFiles({ limit: 2, path: tmpDir });
expect(result.files.length).toBe(2);
expect(result.totalCount).toBe(3);
});
it('should handle non-existent directory', async () => {
const result = await listLocalFiles({ path: path.join(tmpDir, 'nope') });
expect(result.files).toEqual([]);
expect(result.totalCount).toBe(0);
});
it('should mark directories correctly', async () => {
await mkdir(path.join(tmpDir, 'mydir'));
const result = await listLocalFiles({ path: tmpDir });
const dir = result.files.find((f) => f.name === 'mydir');
expect(dir!.isDirectory).toBe(true);
expect(dir!.type).toBe('directory');
});
});
// ─── moveLocalFiles ───
describe('moveLocalFiles', () => {
it('should move a file', async () => {
const src = path.join(tmpDir, 'src.txt');
const dst = path.join(tmpDir, 'dst.txt');
await writeFile(src, 'content');
const result = await moveLocalFiles({
items: [{ newPath: dst, oldPath: src }],
});
expect(result[0].success).toBe(true);
expect(result[0].newPath).toBe(dst);
expect(fs.existsSync(dst)).toBe(true);
expect(fs.existsSync(src)).toBe(false);
});
it('should handle identical source and target', async () => {
const filePath = path.join(tmpDir, 'same.txt');
await writeFile(filePath, 'content');
const result = await moveLocalFiles({
items: [{ newPath: filePath, oldPath: filePath }],
});
expect(result[0].success).toBe(true);
});
it('should return error for non-existent source', async () => {
const result = await moveLocalFiles({
items: [{ newPath: path.join(tmpDir, 'dst.txt'), oldPath: path.join(tmpDir, 'nope.txt') }],
});
expect(result[0].success).toBe(false);
expect(result[0].error).toContain('Source path not found');
});
it('should return empty array for empty items', async () => {
const result = await moveLocalFiles({ items: [] });
expect(result).toEqual([]);
});
it('should create target directory if missing', async () => {
const src = path.join(tmpDir, 'src.txt');
const dst = path.join(tmpDir, 'new', 'dir', 'dst.txt');
await writeFile(src, 'content');
const result = await moveLocalFiles({
items: [{ newPath: dst, oldPath: src }],
});
expect(result[0].success).toBe(true);
expect(fs.existsSync(dst)).toBe(true);
});
});
// ─── renameLocalFile ───
describe('renameLocalFile', () => {
it('should rename a file', async () => {
const filePath = path.join(tmpDir, 'old.txt');
await writeFile(filePath, 'content');
const result = await renameLocalFile({ newName: 'new.txt', path: filePath });
expect(result.success).toBe(true);
expect(result.newPath).toBe(path.join(tmpDir, 'new.txt'));
expect(fs.existsSync(path.join(tmpDir, 'new.txt'))).toBe(true);
});
it('should return error for empty params', async () => {
const result = await renameLocalFile({ newName: '', path: '' });
expect(result.success).toBe(false);
});
it('should reject invalid characters in new name', async () => {
const result = await renameLocalFile({
newName: 'bad/name',
path: path.join(tmpDir, 'file.txt'),
});
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid new name');
});
it('should handle identical name (no-op)', async () => {
const filePath = path.join(tmpDir, 'same.txt');
await writeFile(filePath, 'content');
const result = await renameLocalFile({ newName: 'same.txt', path: filePath });
expect(result.success).toBe(true);
});
it('should return error for non-existent file', async () => {
const result = await renameLocalFile({
newName: 'new.txt',
path: path.join(tmpDir, 'nope.txt'),
});
expect(result.success).toBe(false);
});
});
// ─── globLocalFiles ───
describe('globLocalFiles', () => {
it('should match glob patterns', async () => {
await writeFile(path.join(tmpDir, 'a.ts'), 'a');
await writeFile(path.join(tmpDir, 'b.ts'), 'b');
await writeFile(path.join(tmpDir, 'c.js'), 'c');
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.ts' });
expect(result.files.length).toBe(2);
expect(result.files).toContain('a.ts');
expect(result.files).toContain('b.ts');
});
it('should ignore node_modules and .git', async () => {
await mkdir(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
await writeFile(path.join(tmpDir, 'node_modules', 'pkg', 'index.ts'), 'x');
await writeFile(path.join(tmpDir, 'src.ts'), 'y');
const result = await globLocalFiles({ cwd: tmpDir, pattern: '**/*.ts' });
expect(result.files).toEqual(['src.ts']);
});
});
// ─── grepContent ───
describe('grepContent', () => {
it('should return matches', async () => {
await writeFile(path.join(tmpDir, 'search.txt'), 'hello world\nfoo bar\nhello again');
const result = await grepContent({ cwd: tmpDir, pattern: 'hello' });
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('matches');
});
it('should handle no matches', async () => {
await writeFile(path.join(tmpDir, 'empty.txt'), 'nothing here');
const result = await grepContent({ cwd: tmpDir, pattern: 'xyz_not_found' });
expect(result.matches).toEqual([]);
});
});
// ─── searchLocalFiles ───
describe('searchLocalFiles', () => {
it('should find files by keyword', async () => {
await writeFile(path.join(tmpDir, 'config.json'), '{}');
await writeFile(path.join(tmpDir, 'config.yaml'), '');
await writeFile(path.join(tmpDir, 'readme.md'), '');
const result = await searchLocalFiles({ directory: tmpDir, keywords: 'config' });
expect(result.length).toBe(2);
expect(result.map((r) => r.name)).toContain('config.json');
});
it('should respect limit', async () => {
for (let i = 0; i < 5; i++) {
await writeFile(path.join(tmpDir, `file${i}.log`), `content ${i}`);
}
const result = await searchLocalFiles({
directory: tmpDir,
keywords: 'file',
limit: 2,
});
expect(result.length).toBe(2);
});
it('should handle errors gracefully', async () => {
const result = await searchLocalFiles({
directory: '/nonexistent/path/xyz',
keywords: 'test',
});
expect(result).toEqual([]);
});
});
});

View file

@ -0,0 +1,59 @@
import { readFile, writeFile } from 'node:fs/promises';
import { createPatch } from 'diff';
import type { EditFileParams, EditFileResult } from '../types';
export async function editLocalFile({
file_path: filePath,
old_string,
new_string,
replace_all = false,
}: EditFileParams): Promise<EditFileResult> {
try {
const content = await readFile(filePath, 'utf8');
if (!content.includes(old_string)) {
return {
error: 'The specified old_string was not found in the file',
replacements: 0,
success: false,
};
}
let newContent: string;
let replacements: number;
if (replace_all) {
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
const matches = content.match(regex);
replacements = matches ? matches.length : 0;
newContent = content.replaceAll(old_string, new_string);
} else {
const index = content.indexOf(old_string);
if (index === -1) {
return { error: 'Old string not found', replacements: 0, success: false };
}
newContent = content.slice(0, index) + new_string + content.slice(index + old_string.length);
replacements = 1;
}
await writeFile(filePath, newContent, 'utf8');
const patch = createPatch(filePath, content, newContent, '', '');
const diffText = `diff --git a${filePath} b${filePath}\n${patch}`;
const patchLines = patch.split('\n');
let linesAdded = 0;
let linesDeleted = 0;
for (const line of patchLines) {
if (line.startsWith('+') && !line.startsWith('+++')) linesAdded++;
else if (line.startsWith('-') && !line.startsWith('---')) linesDeleted++;
}
return { diffText, linesAdded, linesDeleted, replacements, success: true };
} catch (error) {
return { error: (error as Error).message, replacements: 0, success: false };
}
}

View file

@ -0,0 +1,16 @@
import fg from 'fast-glob';
import type { GlobFilesParams, GlobFilesResult } from '../types';
export async function globLocalFiles({ pattern, cwd }: GlobFilesParams): Promise<GlobFilesResult> {
try {
const files = await fg(pattern, {
cwd: cwd || process.cwd(),
dot: false,
ignore: ['**/node_modules/**', '**/.git/**'],
});
return { files };
} catch (error) {
return { error: (error as Error).message, files: [] };
}
}

View file

@ -0,0 +1,54 @@
import { spawn } from 'node:child_process';
import type { GrepContentParams, GrepContentResult } from '../types';
export async function grepContent({
pattern,
cwd,
filePattern,
}: GrepContentParams): Promise<GrepContentResult> {
return new Promise<GrepContentResult>((resolve) => {
const args = ['--json', '-n'];
if (filePattern) args.push('--glob', filePattern);
args.push(pattern);
const child = spawn('rg', args, { cwd: cwd || process.cwd() });
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', () => {
// stderr consumed but not used
});
child.on('close', (code) => {
if (code !== 0 && code !== 1) {
resolve({ matches: [], success: false });
return;
}
try {
const matches = stdout
.split('\n')
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
resolve({ matches, success: true });
} catch {
resolve({ matches: [], success: true });
}
});
child.on('error', () => {
resolve({ matches: [], success: false });
});
});
}

View file

@ -0,0 +1,10 @@
export { editLocalFile } from './edit';
export { globLocalFiles } from './glob';
export { grepContent } from './grep';
export type { ListFilesOptions } from './list';
export { listLocalFiles } from './list';
export { moveLocalFiles } from './move';
export { readLocalFile } from './read';
export { renameLocalFile } from './rename';
export { searchLocalFiles } from './search';
export { writeLocalFile } from './write';

View file

@ -0,0 +1,78 @@
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { SYSTEM_FILES_TO_IGNORE } from '@lobechat/file-loaders';
import type { FileEntry, ListFilesParams, ListFilesResult } from '../types';
export interface ListFilesOptions {
/** Whether to filter out system files like .DS_Store, Thumbs.db, etc. */
ignoreSystemFiles?: boolean;
}
export async function listLocalFiles(
{ path: dirPath, sortBy = 'modifiedTime', sortOrder = 'desc', limit = 100 }: ListFilesParams,
options?: ListFilesOptions,
): Promise<ListFilesResult> {
const { ignoreSystemFiles = true } = options || {};
try {
const entries = await readdir(dirPath);
const results: FileEntry[] = [];
for (const entry of entries) {
if (ignoreSystemFiles && SYSTEM_FILES_TO_IGNORE.includes(entry)) {
continue;
}
const fullPath = path.join(dirPath, entry);
try {
const stats = await stat(fullPath);
const isDirectory = stats.isDirectory();
results.push({
createdTime: stats.birthtime,
isDirectory,
lastAccessTime: stats.atime,
modifiedTime: stats.mtime,
name: entry,
path: fullPath,
size: stats.size,
type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
});
} catch {
// Skip files we can't stat
}
}
results.sort((a, b) => {
let comparison: number;
switch (sortBy) {
case 'name': {
comparison = (a.name || '').localeCompare(b.name || '');
break;
}
case 'modifiedTime': {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
break;
}
case 'createdTime': {
comparison = a.createdTime.getTime() - b.createdTime.getTime();
break;
}
case 'size': {
comparison = a.size - b.size;
break;
}
default: {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
}
}
return sortOrder === 'desc' ? -comparison : comparison;
});
const totalCount = results.length;
return { files: results.slice(0, limit), totalCount };
} catch {
return { files: [], totalCount: 0 };
}
}

View file

@ -0,0 +1,80 @@
import { constants } from 'node:fs';
import { access, mkdir, rename } from 'node:fs/promises';
import path from 'node:path';
import type { MoveFileResultItem, MoveFilesParams } from '../types';
export async function moveLocalFiles({ items }: MoveFilesParams): Promise<MoveFileResultItem[]> {
const results: MoveFileResultItem[] = [];
if (!items || items.length === 0) {
return [];
}
for (const item of items) {
const { oldPath: sourcePath, newPath } = item;
const resultItem: MoveFileResultItem = {
newPath: undefined,
sourcePath,
success: false,
};
if (!sourcePath || !newPath) {
resultItem.error = 'Both oldPath and newPath are required for each item.';
results.push(resultItem);
continue;
}
try {
// Check if source exists
try {
await access(sourcePath, constants.F_OK);
} catch (accessError: any) {
if (accessError.code === 'ENOENT') {
throw new Error(`Source path not found: ${sourcePath}`, { cause: accessError });
} else {
throw new Error(
`Permission denied accessing source path: ${sourcePath}. ${accessError.message}`,
{ cause: accessError },
);
}
}
// Check if paths are identical
if (path.normalize(sourcePath) === path.normalize(newPath)) {
resultItem.success = true;
resultItem.newPath = newPath;
results.push(resultItem);
continue;
}
// Ensure target directory exists
const targetDir = path.dirname(newPath);
await mkdir(targetDir, { recursive: true });
// Execute move
await rename(sourcePath, newPath);
resultItem.success = true;
resultItem.newPath = newPath;
} catch (error) {
let errorMessage = (error as Error).message;
const code = (error as any).code;
if (code === 'ENOENT') errorMessage = `Source path not found: ${sourcePath}.`;
else if (code === 'EPERM' || code === 'EACCES')
errorMessage = `Permission denied to move the item at ${sourcePath}. Check file/folder permissions.`;
else if (code === 'EBUSY')
errorMessage = `The file or directory at ${sourcePath} or ${newPath} is busy or locked by another process.`;
else if (code === 'EXDEV')
errorMessage = `Cannot move across different file systems or drives. Source: ${sourcePath}, Target: ${newPath}.`;
else if (code === 'EISDIR')
errorMessage = `Cannot overwrite a directory with a file, or vice versa. Source: ${sourcePath}, Target: ${newPath}.`;
else if (code === 'ENOTEMPTY') errorMessage = `The target directory ${newPath} is not empty.`;
else if (code === 'EEXIST')
errorMessage = `An item already exists at the target path: ${newPath}.`;
resultItem.error = errorMessage;
}
results.push(resultItem);
}
return results;
}

View file

@ -0,0 +1,101 @@
import { stat } from 'node:fs/promises';
import path from 'node:path';
import { loadFile } from '@lobechat/file-loaders';
import type { ReadFileParams, ReadFileResult } from '../types';
export async function readLocalFile({
path: filePath,
loc,
fullContent,
}: ReadFileParams): Promise<ReadFileResult> {
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
try {
const fileDocument = await loadFile(filePath);
// loadFile returns error in metadata instead of throwing
if (fileDocument.metadata?.error) {
return {
charCount: 0,
content: `Error accessing or processing file: ${fileDocument.metadata.error}`,
createdTime: fileDocument.createdTime,
fileType: fileDocument.fileType || 'unknown',
filename: fileDocument.filename,
lineCount: 0,
loc: [0, 0],
modifiedTime: fileDocument.modifiedTime,
totalCharCount: 0,
totalLineCount: 0,
};
}
const lines = fileDocument.content.split('\n');
const totalLineCount = lines.length;
const totalCharCount = fileDocument.content.length;
let content: string;
let charCount: number;
let lineCount: number;
let actualLoc: [number, number];
if (effectiveLoc === undefined) {
content = fileDocument.content;
charCount = totalCharCount;
lineCount = totalLineCount;
actualLoc = [0, totalLineCount];
} else {
const [startLine, endLine] = effectiveLoc;
const selectedLines = lines.slice(startLine, endLine);
content = selectedLines.join('\n');
charCount = content.length;
lineCount = selectedLines.length;
actualLoc = effectiveLoc;
}
const fileType = fileDocument.fileType || 'unknown';
const result: ReadFileResult = {
charCount,
content,
createdTime: fileDocument.createdTime,
fileType,
filename: fileDocument.filename,
lineCount,
loc: actualLoc,
modifiedTime: fileDocument.modifiedTime,
totalCharCount,
totalLineCount,
};
try {
const stats = await stat(filePath);
if (stats.isDirectory()) {
result.content = 'This is a directory and cannot be read as plain text.';
result.charCount = 0;
result.lineCount = 0;
result.totalCharCount = 0;
result.totalLineCount = 0;
}
} catch {
// Ignore stat errors
}
return result;
} catch (error) {
const errorMessage = (error as Error).message;
return {
charCount: 0,
content: `Error accessing or processing file: ${errorMessage}`,
createdTime: new Date(),
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
filename: path.basename(filePath),
lineCount: 0,
loc: [0, 0],
modifiedTime: new Date(),
totalCharCount: 0,
totalLineCount: 0,
};
}
}

View file

@ -0,0 +1,64 @@
import { rename } from 'node:fs/promises';
import path from 'node:path';
import type { RenameFileParams, RenameFileResult } from '../types';
export async function renameLocalFile({
path: currentPath,
newName,
}: RenameFileParams): Promise<RenameFileResult> {
if (!currentPath || !newName) {
return { error: 'Both path and newName are required.', newPath: '', success: false };
}
// Prevent path traversal or invalid characters
if (
newName.includes('/') ||
newName.includes('\\') ||
newName === '.' ||
newName === '..' ||
/["*/:<>?\\|]/.test(newName)
) {
return {
error:
'Invalid new name. It cannot contain path separators (/, \\), be "." or "..", or include characters like < > : " / \\ | ? *.',
newPath: '',
success: false,
};
}
let newPath: string;
try {
const dir = path.dirname(currentPath);
newPath = path.join(dir, newName);
if (path.normalize(currentPath) === path.normalize(newPath)) {
return { newPath, success: true };
}
} catch (error) {
return {
error: `Internal error calculating the new path: ${(error as Error).message}`,
newPath: '',
success: false,
};
}
try {
await rename(currentPath, newPath);
return { newPath, success: true };
} catch (error) {
let errorMessage = (error as Error).message;
const code = (error as any).code;
if (code === 'ENOENT')
errorMessage = `File or directory not found at the original path: ${currentPath}.`;
else if (code === 'EPERM' || code === 'EACCES')
errorMessage = `Permission denied to rename the item at ${currentPath}. Check file/folder permissions.`;
else if (code === 'EBUSY')
errorMessage = `The file or directory at ${currentPath} or ${newPath} is busy or locked by another process.`;
else if (code === 'EISDIR' || code === 'ENOTDIR')
errorMessage = `Cannot rename - conflict between file and directory. Source: ${currentPath}, Target: ${newPath}.`;
else if (code === 'EEXIST')
errorMessage = `Cannot rename: an item with the name '${newName}' already exists at this location.`;
return { error: errorMessage, newPath: '', success: false };
}
}

View file

@ -0,0 +1,43 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import fg from 'fast-glob';
import type { SearchFilesParams, SearchFilesResult } from '../types';
export async function searchLocalFiles({
keywords,
directory,
contentContains,
limit = 30,
}: SearchFilesParams): Promise<SearchFilesResult[]> {
try {
const cwd = directory || process.cwd();
const files = await fg(`**/*${keywords}*`, {
cwd,
dot: false,
ignore: ['**/node_modules/**', '**/.git/**'],
});
let results = files.map((f) => ({ name: path.basename(f), path: path.join(cwd, f) }));
if (contentContains) {
const filtered: typeof results = [];
for (const file of results) {
try {
const content = await readFile(file.path, 'utf8');
if (content.includes(contentContains)) {
filtered.push(file);
}
} catch {
// Skip unreadable files
}
}
results = filtered;
}
return results.slice(0, limit);
} catch {
return [];
}
}

View file

@ -0,0 +1,21 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { WriteFileParams, WriteFileResult } from '../types';
export async function writeLocalFile({
path: filePath,
content,
}: WriteFileParams): Promise<WriteFileResult> {
if (!filePath) return { error: 'Path cannot be empty', success: false };
if (content === undefined) return { error: 'Content cannot be empty', success: false };
try {
const dirname = path.dirname(filePath);
await mkdir(dirname, { recursive: true });
await writeFile(filePath, content, 'utf8');
return { success: true };
} catch (error) {
return { error: `Failed to write file: ${(error as Error).message}`, success: false };
}
}

View file

@ -0,0 +1,3 @@
export * from './file';
export * from './shell';
export * from './types';

View file

@ -0,0 +1,254 @@
import type { ChildProcess } from 'node:child_process';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ShellProcessManager } from '../process-manager';
function createMockProcess(exitCode: number | null = null): ChildProcess {
return {
exitCode,
kill: vi.fn(),
} as unknown as ChildProcess;
}
describe('ShellProcessManager', () => {
let manager: ShellProcessManager;
beforeEach(() => {
manager = new ShellProcessManager();
});
describe('getOutput', () => {
it('should return error for non-existent shell_id', () => {
const result = manager.getOutput({ shell_id: 'non-existent' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
expect(result.running).toBe(false);
});
it('should retrieve stdout and stderr', () => {
const process = createMockProcess();
manager.register('test-1', {
lastReadStderr: 0,
lastReadStdout: 0,
process,
stderr: ['error line\n'],
stdout: ['line 1\n', 'line 2\n'],
});
const result = manager.getOutput({ shell_id: 'test-1' });
expect(result.success).toBe(true);
expect(result.stdout).toContain('line 1');
expect(result.stdout).toContain('line 2');
expect(result.stderr).toContain('error line');
});
it('should only return new output since last read', () => {
const process = createMockProcess();
const shellProcess = {
lastReadStderr: 0,
lastReadStdout: 0,
process,
stderr: [] as string[],
stdout: ['first\n'],
};
manager.register('test-1', shellProcess);
const first = manager.getOutput({ shell_id: 'test-1' });
expect(first.stdout).toContain('first');
// No new output
const second = manager.getOutput({ shell_id: 'test-1' });
expect(second.stdout).toBe('');
expect(second.stderr).toBe('');
// Add new output
shellProcess.stdout.push('second\n');
const third = manager.getOutput({ shell_id: 'test-1' });
expect(third.stdout).toContain('second');
expect(third.stdout).not.toContain('first');
});
it('should track stdout and stderr offsets separately', () => {
const process = createMockProcess();
const shellProcess = {
lastReadStderr: 0,
lastReadStdout: 0,
process,
stderr: [] as string[],
stdout: [] as string[],
};
manager.register('test-1', shellProcess);
// Add stderr only
shellProcess.stderr.push('error 1\n');
const read1 = manager.getOutput({ shell_id: 'test-1' });
expect(read1.stderr).toBe('error 1\n');
expect(read1.stdout).toBe('');
// Add stdout only
shellProcess.stdout.push('output 1\n');
const read2 = manager.getOutput({ shell_id: 'test-1' });
expect(read2.stdout).toBe('output 1\n');
expect(read2.stderr).toBe('');
});
it('should filter output with regex', () => {
const process = createMockProcess();
manager.register('test-1', {
lastReadStderr: 0,
lastReadStdout: 0,
process,
stderr: [],
stdout: ['line 1\n', 'line 2\n', 'line 3\n'],
});
const result = manager.getOutput({ filter: 'line 1', shell_id: 'test-1' });
expect(result.success).toBe(true);
expect(result.output).toContain('line 1');
expect(result.output).not.toContain('line 2');
});
it('should handle invalid regex filter gracefully', () => {
const process = createMockProcess();
manager.register('test-1', {
lastReadStderr: 0,
lastReadStdout: 0,
process,
stderr: [],
stdout: ['output\n'],
});
const result = manager.getOutput({ filter: '[invalid(regex', shell_id: 'test-1' });
expect(result.success).toBe(true);
// Should return unfiltered output
});
it('should report running status correctly', () => {
const runningProcess = createMockProcess(null);
manager.register('test-1', {
lastReadStderr: 0,
lastReadStdout: 0,
process: runningProcess,
stderr: [],
stdout: [],
});
expect(manager.getOutput({ shell_id: 'test-1' }).running).toBe(true);
// Simulate exit
(runningProcess as any).exitCode = 0;
expect(manager.getOutput({ shell_id: 'test-1' }).running).toBe(false);
});
});
describe('kill', () => {
it('should kill process successfully', () => {
const process = createMockProcess();
manager.register('test-1', {
lastReadStderr: 0,
lastReadStdout: 0,
process,
stderr: [],
stdout: [],
});
const result = manager.kill('test-1');
expect(result.success).toBe(true);
expect(process.kill).toHaveBeenCalled();
});
it('should return error for non-existent shell_id', () => {
const result = manager.kill('non-existent');
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('should remove process from registry after killing', () => {
const process = createMockProcess();
manager.register('test-1', {
lastReadStderr: 0,
lastReadStdout: 0,
process,
stderr: [],
stdout: [],
});
manager.kill('test-1');
const result = manager.getOutput({ shell_id: 'test-1' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('should handle kill error gracefully', () => {
const process = createMockProcess();
(process.kill as any).mockImplementation(() => {
throw new Error('Kill failed');
});
manager.register('test-1', {
lastReadStderr: 0,
lastReadStdout: 0,
process,
stderr: [],
stdout: [],
});
const result = manager.kill('test-1');
expect(result.success).toBe(false);
expect(result.error).toBe('Kill failed');
});
});
describe('cleanupAll', () => {
it('should kill all registered processes', () => {
const p1 = createMockProcess();
const p2 = createMockProcess();
manager.register('test-1', {
lastReadStderr: 0,
lastReadStdout: 0,
process: p1,
stderr: [],
stdout: [],
});
manager.register('test-2', {
lastReadStderr: 0,
lastReadStdout: 0,
process: p2,
stderr: [],
stdout: [],
});
manager.cleanupAll();
expect(p1.kill).toHaveBeenCalled();
expect(p2.kill).toHaveBeenCalled();
expect(manager.getOutput({ shell_id: 'test-1' }).success).toBe(false);
expect(manager.getOutput({ shell_id: 'test-2' }).success).toBe(false);
});
it('should handle kill errors during cleanup', () => {
const p1 = createMockProcess();
(p1.kill as any).mockImplementation(() => {
throw new Error('fail');
});
manager.register('test-1', {
lastReadStderr: 0,
lastReadStdout: 0,
process: p1,
stderr: [],
stdout: [],
});
// Should not throw
expect(() => manager.cleanupAll()).not.toThrow();
});
});
});

View file

@ -0,0 +1,198 @@
import { afterEach, describe, expect, it } from 'vitest';
import { ShellProcessManager } from '../process-manager';
import { runCommand } from '../runner';
describe('runCommand', () => {
const processManager = new ShellProcessManager();
afterEach(() => {
processManager.cleanupAll();
});
describe('synchronous mode', () => {
it('should execute a simple command', async () => {
const result = await runCommand({ command: 'echo hello' }, { processManager });
expect(result.success).toBe(true);
expect(result.stdout).toContain('hello');
expect(result.exit_code).toBe(0);
});
it('should capture stderr', async () => {
const result = await runCommand({ command: 'echo error >&2' }, { processManager });
expect(result.stderr).toContain('error');
});
it('should handle command failure', async () => {
const result = await runCommand({ command: 'exit 1' }, { processManager });
expect(result.success).toBe(false);
expect(result.exit_code).toBe(1);
});
it('should handle command not found', async () => {
const result = await runCommand(
{ command: 'nonexistent_command_xyz_123' },
{ processManager },
);
expect(result.success).toBe(false);
});
it('should timeout long-running commands', async () => {
const result = await runCommand({ command: 'sleep 10', timeout: 500 }, { processManager });
expect(result.success).toBe(false);
expect(result.error).toContain('timed out');
}, 10000);
it('should strip ANSI codes from output', async () => {
const result = await runCommand(
{ command: 'printf "\\033[31mred\\033[0m"' },
{ processManager },
);
expect(result.output).not.toContain('\u001B');
});
it('should truncate very long output', async () => {
const result = await runCommand(
{
command: `python3 -c "print('x' * 100000)" 2>/dev/null || printf '%0.sx' $(seq 1 100000)`,
},
{ processManager },
);
expect(result.output!.length).toBeLessThanOrEqual(85000);
}, 15000);
it('should pass cwd to command', async () => {
const result = await runCommand({ command: 'pwd', cwd: '/tmp' }, { processManager });
expect(result.success).toBe(true);
expect(result.stdout).toContain('/tmp');
});
});
describe('background mode', () => {
it('should run command in background', async () => {
const result = await runCommand(
{ command: 'echo background', run_in_background: true },
{ processManager },
);
expect(result.success).toBe(true);
expect(result.shell_id).toBeDefined();
});
it('should capture background process output', async () => {
const bgResult = await runCommand(
{ command: 'echo hello && sleep 0.1', run_in_background: true },
{ processManager },
);
await new Promise((r) => setTimeout(r, 200));
const output = processManager.getOutput({ shell_id: bgResult.shell_id! });
expect(output.success).toBe(true);
expect(output.stdout).toContain('hello');
});
it('should return new output only on subsequent reads', async () => {
const bgResult = await runCommand(
{ command: 'echo first && sleep 0.2 && echo second', run_in_background: true },
{ processManager },
);
await new Promise((r) => setTimeout(r, 100));
const first = processManager.getOutput({ shell_id: bgResult.shell_id! });
expect(first.stdout).toContain('first');
await new Promise((r) => setTimeout(r, 300));
processManager.getOutput({ shell_id: bgResult.shell_id! });
// First read should have had "first"
expect(first.stdout).toContain('first');
});
});
describe('process management', () => {
it('should kill a background process', async () => {
const bgResult = await runCommand(
{ command: 'sleep 60', run_in_background: true },
{ processManager },
);
const result = processManager.kill(bgResult.shell_id!);
expect(result.success).toBe(true);
});
it('should return error for unknown shell_id', async () => {
const result = processManager.getOutput({ shell_id: 'unknown-id' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('should return error when killing unknown shell_id', async () => {
const result = processManager.kill('unknown-id');
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('should support filter parameter', async () => {
const bgResult = await runCommand(
{ command: 'echo "line1\nline2\nline3"', run_in_background: true },
{ processManager },
);
await new Promise((r) => setTimeout(r, 200));
const output = processManager.getOutput({
filter: 'line2',
shell_id: bgResult.shell_id!,
});
expect(output.success).toBe(true);
});
it('should handle invalid filter regex', async () => {
const bgResult = await runCommand(
{ command: 'echo test', run_in_background: true },
{ processManager },
);
await new Promise((r) => setTimeout(r, 200));
const output = processManager.getOutput({
filter: '[invalid',
shell_id: bgResult.shell_id!,
});
expect(output.success).toBe(true);
});
it('should track running state', async () => {
const bgResult = await runCommand(
{ command: 'sleep 5', run_in_background: true },
{ processManager },
);
const output = processManager.getOutput({ shell_id: bgResult.shell_id! });
expect(output.running).toBe(true);
});
});
it('should work with logger', async () => {
const mockLogger = { debug: () => {}, error: () => {}, info: () => {} };
const result = await runCommand(
{ command: 'echo test', description: 'test' },
{ logger: mockLogger, processManager },
);
expect(result.success).toBe(true);
});
});

View file

@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { getShellConfig, MAX_OUTPUT_LENGTH, stripAnsi, truncateOutput } from '../utils';
describe('stripAnsi', () => {
it('should strip ANSI color codes', () => {
expect(stripAnsi('\x1B[31mred\x1B[0m')).toBe('red');
});
it('should strip complex ANSI sequences', () => {
expect(stripAnsi('\x1B[38;5;250m███████╗\x1B[0m')).toBe('███████╗');
});
it('should strip bold/bright codes', () => {
expect(stripAnsi('\x1B[1;32mSuccess\x1B[0m')).toBe('Success');
});
it('should handle string without ANSI codes', () => {
expect(stripAnsi('plain text')).toBe('plain text');
});
it('should handle empty string', () => {
expect(stripAnsi('')).toBe('');
});
it('should strip multiple ANSI sequences', () => {
const input = '\x1B[33mwarning:\x1B[0m something \x1B[31mhappened\x1B[0m';
expect(input).toContain('\x1B[');
expect(stripAnsi(input)).toBe('warning: something happened');
expect(stripAnsi(input)).not.toContain('\x1B[');
});
});
describe('truncateOutput', () => {
it('should return string as-is when within limit', () => {
expect(truncateOutput('short', 100)).toBe('short');
});
it('should truncate long string with indicator', () => {
const long = 'x'.repeat(200);
const result = truncateOutput(long, 100);
expect(result.length).toBeLessThan(200);
expect(result).toContain('truncated');
expect(result).toContain('more characters');
});
it('should strip ANSI before checking length', () => {
const colored = '\x1B[31m' + 'x'.repeat(50) + '\x1B[0m';
const result = truncateOutput(colored, 100);
expect(result).toBe('x'.repeat(50));
});
it('should use MAX_OUTPUT_LENGTH as default', () => {
const long = 'x'.repeat(MAX_OUTPUT_LENGTH + 1000);
const result = truncateOutput(long);
expect(result).toContain('truncated');
expect(result.length).toBeLessThan(long.length);
});
});
describe('getShellConfig', () => {
it('should return shell config for current platform', () => {
const config = getShellConfig('echo hello');
if (process.platform === 'win32') {
expect(config.cmd).toBe('cmd.exe');
expect(config.args).toEqual(['/c', 'echo hello']);
} else {
expect(config.cmd).toBe('/bin/sh');
expect(config.args).toEqual(['-c', 'echo hello']);
}
});
});

View file

@ -0,0 +1,5 @@
export type { ShellProcess } from './process-manager';
export { ShellProcessManager } from './process-manager';
export type { RunCommandOptions } from './runner';
export { runCommand } from './runner';
export { getShellConfig, MAX_OUTPUT_LENGTH, stripAnsi, truncateOutput } from './utils';

View file

@ -0,0 +1,89 @@
import type { ChildProcess } from 'node:child_process';
import type { GetCommandOutputParams, GetCommandOutputResult, KillCommandResult } from '../types';
import { truncateOutput } from './utils';
export interface ShellProcess {
lastReadStderr: number;
lastReadStdout: number;
process: ChildProcess;
stderr: string[];
stdout: string[];
}
export class ShellProcessManager {
private processes = new Map<string, ShellProcess>();
register(shellId: string, shellProcess: ShellProcess): void {
this.processes.set(shellId, shellProcess);
}
getOutput({ shell_id, filter }: GetCommandOutputParams): GetCommandOutputResult {
const shellProcess = this.processes.get(shell_id);
if (!shellProcess) {
return {
error: `Shell ID ${shell_id} not found`,
output: '',
running: false,
stderr: '',
stdout: '',
success: false,
};
}
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
const newStdout = stdout.slice(lastReadStdout).join('');
const newStderr = stderr.slice(lastReadStderr).join('');
let output = newStdout + newStderr;
if (filter) {
try {
const regex = new RegExp(filter, 'gm');
const lines = output.split('\n');
output = lines.filter((line) => regex.test(line)).join('\n');
} catch {
// Invalid filter regex, use unfiltered output
}
}
shellProcess.lastReadStdout = stdout.length;
shellProcess.lastReadStderr = stderr.length;
const running = childProcess.exitCode === null;
return {
output: truncateOutput(output),
running,
stderr: truncateOutput(newStderr),
stdout: truncateOutput(newStdout),
success: true,
};
}
kill(shell_id: string): KillCommandResult {
const shellProcess = this.processes.get(shell_id);
if (!shellProcess) {
return { error: `Shell ID ${shell_id} not found`, success: false };
}
try {
shellProcess.process.kill();
this.processes.delete(shell_id);
return { success: true };
} catch (error) {
return { error: (error as Error).message, success: false };
}
}
cleanupAll(): void {
for (const [id, sp] of this.processes) {
try {
sp.process.kill();
} catch {
// Ignore
}
this.processes.delete(id);
}
}
}

View file

@ -0,0 +1,121 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { RunCommandParams, RunCommandResult } from '../types';
import type { ShellProcess, ShellProcessManager } from './process-manager';
import { getShellConfig, truncateOutput } from './utils';
export interface RunCommandOptions {
logger?: {
debug: (...args: any[]) => void;
error: (...args: any[]) => void;
info: (...args: any[]) => void;
};
processManager: ShellProcessManager;
}
export async function runCommand(
{ command, cwd, description, run_in_background, timeout = 120_000 }: RunCommandParams,
{ processManager, logger }: RunCommandOptions,
): Promise<RunCommandResult> {
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
logger?.debug(`${logPrefix} Starting`, { background: run_in_background, cwd, timeout });
const effectiveTimeout = Math.min(Math.max(timeout, 1000), 600_000);
const shellConfig = getShellConfig(command);
try {
if (run_in_background) {
const shellId = randomUUID();
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
cwd,
env: process.env,
shell: false,
});
const shellProcess: ShellProcess = {
lastReadStderr: 0,
lastReadStdout: 0,
process: childProcess,
stderr: [],
stdout: [],
};
childProcess.stdout?.on('data', (data) => {
shellProcess.stdout.push(data.toString());
});
childProcess.stderr?.on('data', (data) => {
shellProcess.stderr.push(data.toString());
});
childProcess.on('exit', (code) => {
logger?.debug(`${logPrefix} Background process exited`, { code, shellId });
});
processManager.register(shellId, shellProcess);
logger?.info?.(`${logPrefix} Started background`, { shellId });
return { shell_id: shellId, success: true };
} else {
return new Promise<RunCommandResult>((resolve) => {
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
cwd,
env: process.env,
shell: false,
});
let stdout = '';
let stderr = '';
let killed = false;
const timeoutHandle = setTimeout(() => {
killed = true;
childProcess.kill();
resolve({
error: `Command timed out after ${effectiveTimeout}ms`,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
}, effectiveTimeout);
childProcess.stdout?.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('exit', (code) => {
if (!killed) {
clearTimeout(timeoutHandle);
const success = code === 0;
logger?.info?.(`${logPrefix} Command completed`, { code, success });
resolve({
exit_code: code || 0,
output: truncateOutput(stdout + stderr),
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success,
});
}
});
childProcess.on('error', (error) => {
clearTimeout(timeoutHandle);
logger?.error(`${logPrefix} Command failed:`, error);
resolve({
error: error.message,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
});
});
}
} catch (error) {
return { error: (error as Error).message, success: false };
}
}

View file

@ -0,0 +1,26 @@
/** Maximum output length to prevent context explosion */
export const MAX_OUTPUT_LENGTH = 80_000;
/** Strip ANSI escape codes from terminal output */
// eslint-disable-next-line no-control-regex, regexp/no-obscure-range
const ANSI_REGEX = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
export const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, '');
/** Truncate string to max length with indicator */
export const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
const cleaned = stripAnsi(str);
if (cleaned.length <= maxLength) return cleaned;
return (
cleaned.slice(0, maxLength) +
'\n... [truncated, ' +
(cleaned.length - maxLength) +
' more characters]'
);
};
/** Get cross-platform shell configuration */
export const getShellConfig = (command: string) =>
process.platform === 'win32'
? { args: ['/c', command], cmd: 'cmd.exe' }
: { args: ['-c', command], cmd: '/bin/sh' };

View file

@ -0,0 +1,173 @@
// ─── Shell Types ───
export interface RunCommandParams {
command: string;
cwd?: string;
description?: string;
run_in_background?: boolean;
timeout?: number;
}
export interface RunCommandResult {
error?: string;
exit_code?: number;
output?: string;
shell_id?: string;
stderr?: string;
stdout?: string;
success: boolean;
}
export interface GetCommandOutputParams {
filter?: string;
shell_id: string;
}
export interface GetCommandOutputResult {
error?: string;
output: string;
running: boolean;
stderr: string;
stdout: string;
success: boolean;
}
export interface KillCommandParams {
shell_id: string;
}
export interface KillCommandResult {
error?: string;
success: boolean;
}
// ─── File Types ───
export interface ReadFileParams {
fullContent?: boolean;
loc?: [number, number];
path: string;
}
export interface ReadFileResult {
charCount: number;
content: string;
createdTime: Date;
filename: string;
fileType: string;
lineCount: number;
loc: [number, number];
modifiedTime: Date;
totalCharCount: number;
totalLineCount: number;
}
export interface WriteFileParams {
content: string;
path: string;
}
export interface WriteFileResult {
error?: string;
success: boolean;
}
export interface EditFileParams {
file_path: string;
new_string: string;
old_string: string;
replace_all?: boolean;
}
export interface EditFileResult {
diffText?: string;
error?: string;
linesAdded?: number;
linesDeleted?: number;
replacements: number;
success: boolean;
}
export interface ListFilesParams {
limit?: number;
path: string;
sortBy?: 'createdTime' | 'modifiedTime' | 'name' | 'size';
sortOrder?: 'asc' | 'desc';
}
export interface FileEntry {
createdTime: Date;
isDirectory: boolean;
lastAccessTime: Date;
modifiedTime: Date;
name: string;
path: string;
size: number;
type: string;
}
export interface ListFilesResult {
files: FileEntry[];
totalCount: number;
}
export interface GlobFilesParams {
cwd?: string;
pattern: string;
}
export interface GlobFilesResult {
error?: string;
files: string[];
}
export interface SearchFilesParams {
contentContains?: string;
directory?: string;
keywords: string;
limit?: number;
}
export interface SearchFilesResult {
name: string;
path: string;
}
export interface MoveFileItem {
newPath: string;
oldPath: string;
}
export interface MoveFilesParams {
items: MoveFileItem[];
}
export interface MoveFileResultItem {
error?: string;
newPath?: string;
sourcePath: string;
success: boolean;
}
export interface RenameFileParams {
newName: string;
path: string;
}
export interface RenameFileResult {
error?: string;
newPath: string;
success: boolean;
}
export interface GrepContentParams {
cwd?: string;
filePattern?: string;
pattern: string;
}
export interface GrepContentResult {
error?: string;
matches: any[];
success: boolean;
}

View file

@ -14,6 +14,17 @@ const usageProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
});
export const usageRouter = router({
findAndGroupByDateRange: usageProcedure
.input(
z.object({
endAt: z.string(),
startAt: z.string(),
}),
)
.query(async ({ ctx, input }) => {
return await ctx.usageRecordService.findAndGroupByDateRange(input.startAt, input.endAt);
}),
findAndGroupByDay: usageProcedure
.input(
z.object({

View file

@ -20,30 +20,9 @@ export class UsageRecordService {
}
/**
* @description Find usage records by month.
* @param mo Month
* @returns UsageRecordItem[]
* @description Find usage records by date range.
*/
findByMonth = async (mo?: string): Promise<UsageRecordItem[]> => {
// Set startAt and endAt
let startAt: string;
let endAt: string;
if (mo && dayjs(mo, 'YYYY-MM', true).isValid()) {
// mo format: "YYYY-MM"
startAt = dayjs(mo, 'YYYY-MM').startOf('month').format('YYYY-MM-DD');
endAt = dayjs(mo, 'YYYY-MM').endOf('month').format('YYYY-MM-DD');
} else {
startAt = dayjs().startOf('month').format('YYYY-MM-DD');
endAt = dayjs().endOf('month').format('YYYY-MM-DD');
}
// TODO: To extend to support other features
// - Functionality:
// - More type of usage, e.g. image generation, file processing, summary, search engine.
// - More dimension for analysis, e.g. relational analysis.
// - Performance: Computing asynchronously for performance.
// For now, we only support chat messages for normal users for PoC.
findByDateRange = async (startAt: string, endAt: string): Promise<UsageRecordItem[]> => {
const spends = await this.db
.select({
createdAt: messages.createdAt,
@ -72,35 +51,46 @@ export class UsageRecordService {
metadata: spend.metadata,
model: spend.model,
provider: spend.provider,
spend: metadata?.cost || 0, // Messages do not have a direct cost associated
spend: metadata?.cost || 0,
totalInputTokens: metadata?.totalInputTokens || 0,
totalOutputTokens: metadata?.totalOutputTokens || 0,
totalTokens: (metadata?.totalInputTokens || 0) + (metadata?.totalOutputTokens || 0),
tps: metadata?.tps || 0,
ttft: metadata?.ttft || 0,
type: 'chat', // Default to 'chat' for messages
type: 'chat',
updatedAt: spend.createdAt,
userId: spend.userId,
} as UsageRecordItem;
});
};
findAndGroupByDay = async (mo?: string): Promise<UsageLog[]> => {
// Set startAt and endAt
/**
* @description Find usage records by month.
* @param mo Month
* @returns UsageRecordItem[]
*/
findByMonth = async (mo?: string): Promise<UsageRecordItem[]> => {
let startAt: string;
let endAt: string;
let month: string;
if (mo && dayjs(mo, 'YYYY-MM', true).isValid()) {
// mo format: "YYYY-MM"
startAt = dayjs(mo, 'YYYY-MM').startOf('month').format('YYYY-MM-DD');
endAt = dayjs(mo, 'YYYY-MM').endOf('month').format('YYYY-MM-DD');
month = mo;
} else {
startAt = dayjs().startOf('month').format('YYYY-MM-DD');
endAt = dayjs().endOf('month').format('YYYY-MM-DD');
month = dayjs().format('YYYY-MM');
}
const spends = await this.findByMonth(month);
return this.findByDateRange(startAt, endAt);
};
/**
* @description Group usage records by day for a given date range.
*/
private groupByDay = (
spends: UsageRecordItem[],
startAt: string,
endAt: string,
pad = true,
): UsageLog[] => {
// Clustering by time
const usages = new Map<string, { date: Date; logs: UsageRecordItem[] }>();
spends.forEach((spend) => {
@ -132,15 +122,16 @@ export class UsageRecordService {
records: spends.logs,
totalRequests,
totalSpend,
totalTokens, // Store the formatted date as a string
totalTokens,
});
});
if (!pad) return usageLogs;
// Padding to ensure the date range is complete
const startDate = dayjs(startAt);
const endDate = dayjs(endAt);
const paddedUsageLogs: UsageLog[] = [];
// For every day in the range, check if it exists in usageLogs
// If exists, use it; if not, create a new log with 0 values
log(
'Padding usage logs from',
startDate.format('YYYY-MM-DD'),
@ -148,9 +139,9 @@ export class UsageRecordService {
endDate.format('YYYY-MM-DD'),
);
for (let date = startDate; date.isBefore(endDate); date = date.add(1, 'day')) {
const log = usageLogs.find((log) => log.day === date.format('YYYY-MM-DD'));
if (log) {
paddedUsageLogs.push(log);
const found = usageLogs.find((l) => l.day === date.format('YYYY-MM-DD'));
if (found) {
paddedUsageLogs.push(found);
} else {
paddedUsageLogs.push({
date: date.toDate().getTime(),
@ -164,4 +155,27 @@ export class UsageRecordService {
}
return paddedUsageLogs;
};
findAndGroupByDay = async (mo?: string): Promise<UsageLog[]> => {
let startAt: string;
let endAt: string;
if (mo && dayjs(mo, 'YYYY-MM', true).isValid()) {
startAt = dayjs(mo, 'YYYY-MM').startOf('month').format('YYYY-MM-DD');
endAt = dayjs(mo, 'YYYY-MM').endOf('month').format('YYYY-MM-DD');
} else {
startAt = dayjs().startOf('month').format('YYYY-MM-DD');
endAt = dayjs().endOf('month').format('YYYY-MM-DD');
}
const spends = await this.findByDateRange(startAt, endAt);
return this.groupByDay(spends, startAt, endAt);
};
/**
* @description Find usage grouped by day for a custom date range (e.g. past 12 months).
* Does not pad missing days for large ranges.
*/
findAndGroupByDateRange = async (startAt: string, endAt: string): Promise<UsageLog[]> => {
const spends = await this.findByDateRange(startAt, endAt);
return this.groupByDay(spends, startAt, endAt, false);
};
}