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

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

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

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

* update

* update commands

* update version

* update deps

* refactor version issue

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

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

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

* support update model and provider in cli

* fix desktop build

* fix

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

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

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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