mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
♻️ 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:
parent
c2e9b45d4c
commit
860e11ab3a
56 changed files with 4313 additions and 2560 deletions
228
.agents/skills/cli/SKILL.md
Normal file
228
.agents/skills/cli/SKILL.md
Normal 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)
|
||||
144
.agents/skills/cli/references/agent.md
Normal file
144
.agents/skills/cli/references/agent.md
Normal 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.
|
||||
122
.agents/skills/cli/references/conversation.md
Normal file
122
.agents/skills/cli/references/conversation.md
Normal 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.
|
||||
132
.agents/skills/cli/references/generate.md
Normal file
132
.agents/skills/cli/references/generate.md
Normal 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
|
||||
170
.agents/skills/cli/references/knowledge.md
Normal file
170
.agents/skills/cli/references/knowledge.md
Normal 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...]
|
||||
```
|
||||
138
.agents/skills/cli/references/memory.md
Normal file
138
.agents/skills/cli/references/memory.md
Normal 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 |
|
||||
93
.agents/skills/cli/references/models-providers.md
Normal file
93
.agents/skills/cli/references/models-providers.md
Normal 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]
|
||||
```
|
||||
94
.agents/skills/cli/references/search-config.md
Normal file
94
.agents/skills/cli/references/search-config.md
Normal 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"
|
||||
```
|
||||
152
.agents/skills/cli/references/skills-plugins.md
Normal file
152
.agents/skills/cli/references/skills-plugins.md
Normal 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 > ]
|
||||
```
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
packages:
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/local-file-shell'
|
||||
- '../../packages/file-loaders'
|
||||
- '.'
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
47
apps/cli/src/utils/__snapshots__/format.test.ts.snap
Normal file
47
apps/cli/src/utils/__snapshots__/format.test.ts.snap
Normal 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 │
|
||||
└────────────┴────────────────────────┴───────────┴─────────┴───────────┴──────────┴───────┘"
|
||||
`;
|
||||
129
apps/cli/src/utils/format.test.ts
Normal file
129
apps/cli/src/utils/format.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 B–F, 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) => {
|
||||
|
|
|
|||
|
|
@ -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/*"]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ packages:
|
|||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/local-file-shell'
|
||||
- '.'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
13
packages/local-file-shell/package.json
Normal file
13
packages/local-file-shell/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
492
packages/local-file-shell/src/file/__tests__/file.test.ts
Normal file
492
packages/local-file-shell/src/file/__tests__/file.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
packages/local-file-shell/src/file/edit.ts
Normal file
59
packages/local-file-shell/src/file/edit.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
16
packages/local-file-shell/src/file/glob.ts
Normal file
16
packages/local-file-shell/src/file/glob.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
54
packages/local-file-shell/src/file/grep.ts
Normal file
54
packages/local-file-shell/src/file/grep.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
10
packages/local-file-shell/src/file/index.ts
Normal file
10
packages/local-file-shell/src/file/index.ts
Normal 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';
|
||||
78
packages/local-file-shell/src/file/list.ts
Normal file
78
packages/local-file-shell/src/file/list.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
80
packages/local-file-shell/src/file/move.ts
Normal file
80
packages/local-file-shell/src/file/move.ts
Normal 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;
|
||||
}
|
||||
101
packages/local-file-shell/src/file/read.ts
Normal file
101
packages/local-file-shell/src/file/read.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
64
packages/local-file-shell/src/file/rename.ts
Normal file
64
packages/local-file-shell/src/file/rename.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
43
packages/local-file-shell/src/file/search.ts
Normal file
43
packages/local-file-shell/src/file/search.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
21
packages/local-file-shell/src/file/write.ts
Normal file
21
packages/local-file-shell/src/file/write.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
3
packages/local-file-shell/src/index.ts
Normal file
3
packages/local-file-shell/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './file';
|
||||
export * from './shell';
|
||||
export * from './types';
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
198
packages/local-file-shell/src/shell/__tests__/runner.test.ts
Normal file
198
packages/local-file-shell/src/shell/__tests__/runner.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
packages/local-file-shell/src/shell/__tests__/utils.test.ts
Normal file
74
packages/local-file-shell/src/shell/__tests__/utils.test.ts
Normal 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']);
|
||||
}
|
||||
});
|
||||
});
|
||||
5
packages/local-file-shell/src/shell/index.ts
Normal file
5
packages/local-file-shell/src/shell/index.ts
Normal 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';
|
||||
89
packages/local-file-shell/src/shell/process-manager.ts
Normal file
89
packages/local-file-shell/src/shell/process-manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
packages/local-file-shell/src/shell/runner.ts
Normal file
121
packages/local-file-shell/src/shell/runner.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
26
packages/local-file-shell/src/shell/utils.ts
Normal file
26
packages/local-file-shell/src/shell/utils.ts
Normal 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' };
|
||||
173
packages/local-file-shell/src/types.ts
Normal file
173
packages/local-file-shell/src/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue