@vietor/easy-agent 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ const CUSTOM_LABEL = "✎ Custom input";
6
+ export function QuestionView({ question, onAnswer }) {
7
+ const hasOptions = question.options.length > 0;
8
+ const items = hasOptions ? [...question.options, CUSTOM_LABEL] : [];
9
+ const [selected, setSelected] = useState(0);
10
+ const [mode, setMode] = useState(hasOptions ? "select" : "input");
11
+ const [text, setText] = useState("");
12
+ useInput((input, key) => {
13
+ if (mode === "select") {
14
+ if (key.upArrow) {
15
+ setSelected((i) => (i <= 0 ? items.length - 1 : i - 1));
16
+ }
17
+ else if (key.downArrow) {
18
+ setSelected((i) => (i >= items.length - 1 ? 0 : i + 1));
19
+ }
20
+ else if (key.return) {
21
+ if (selected === items.length - 1)
22
+ setMode("input");
23
+ else
24
+ onAnswer(items[selected]);
25
+ }
26
+ else if (key.escape) {
27
+ onAnswer("");
28
+ }
29
+ else if (input && !key.ctrl && !key.meta) {
30
+ setText(input);
31
+ setMode("input");
32
+ }
33
+ }
34
+ else if (key.escape) {
35
+ if (hasOptions)
36
+ setMode("select");
37
+ else
38
+ onAnswer("");
39
+ }
40
+ });
41
+ if (mode === "input") {
42
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { color: "cyan", children: `? ${question.text}` }), _jsxs(Box, { borderStyle: "single", borderLeft: false, borderRight: false, borderColor: "gray", children: [_jsx(Text, { color: "gray", children: "\u276F " }), _jsx(TextInput, { value: text, onChange: setText, onSubmit: () => onAnswer(text) })] })] }));
43
+ }
44
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { color: "cyan", children: `? ${question.text}` }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", children: items.map((item, i) => (_jsx(Box, { children: _jsxs(Text, { color: i === selected ? "cyan" : undefined, children: [i === selected ? "▸ " : " ", item] }) }, item))) })] }));
45
+ }
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Text } from "ink";
4
4
  import { timeDisplay, compactDisplay } from "../util/format.js";
@@ -9,5 +9,5 @@ export function Spinner({ label, elapsed, promptTokens, completionTokens, }) {
9
9
  const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
10
10
  return () => clearInterval(id);
11
11
  }, []);
12
- return (_jsxs(Text, { color: "gray", children: [SPINNER_FRAMES[frame], " ", label, " \u00B7 ", timeDisplay(elapsed), " \u00B7 \u2191", compactDisplay(promptTokens), " \u00B7 \u2193", compactDisplay(completionTokens)] }));
12
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { children: [" ", label] }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", timeDisplay(elapsed), " \u00B7 \u2191", compactDisplay(promptTokens), " \u00B7 \u2193", compactDisplay(completionTokens)] })] }));
13
13
  }
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo } from "react";
3
+ import { Box, Text } from "ink";
4
+ const ICONS = {
5
+ pending: "○",
6
+ in_progress: "◐",
7
+ completed: "✓",
8
+ };
9
+ const COLORS = {
10
+ pending: "gray",
11
+ in_progress: "yellow",
12
+ completed: "green",
13
+ };
14
+ export const TodoView = memo(function TodoView({ todos }) {
15
+ if (todos.length === 0)
16
+ return null;
17
+ const done = todos.filter((t) => t.status === "completed").length;
18
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { dimColor: true, children: `Tasks [${done}/${todos.length}]` }), todos.map((t, i) => (_jsx(Text, { color: COLORS[t.status], strikethrough: t.status === "completed", children: `${ICONS[t.status]} ${t.content}` }, i)))] }));
19
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vietor/easy-agent",
3
- "version": "0.2.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {
@@ -9,40 +9,37 @@
9
9
  "files": [
10
10
  "dist"
11
11
  ],
12
- "scripts": {
13
- "build": "tsc",
14
- "start": "tsc && node dist/cli.js",
15
- "dev": "tsx src/cli.ts",
16
- "prepublishOnly": "tsc"
12
+ "description": "Terminal-based AI agent CLI with conversational TUI (Ink/React) powered by easy-agent-core",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/vietor/easy-agent.git"
17
17
  },
18
- "keywords": [
19
- "ai",
20
- "cli"
21
- ],
22
- "author": "",
23
- "license": "ISC",
24
- "packageManager": "pnpm@10.30.3",
25
18
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.29.0",
27
- "@vscode/ripgrep": "^1.18.0",
28
- "htmlparser2": "^12.0.0",
29
19
  "ink": "^7.1.0",
30
20
  "ink-text-input": "^6.0.0",
31
21
  "marked": "^18.0.5",
32
- "openai": "^6.45.0",
33
22
  "react": "^19.2.7",
34
23
  "string-width": "^8.2.1",
35
- "turndown": "^7.2.4",
36
- "zod": "^4.4.3"
24
+ "zod": "^4.4.3",
25
+ "@vietor/easy-agent-core": "0.4.1"
37
26
  },
38
27
  "devDependencies": {
39
28
  "@types/node": "^22.0.0",
40
29
  "@types/react": "^19.2.17",
41
- "@types/turndown": "^5.0.6",
42
30
  "tsx": "^4.23.0",
43
31
  "typescript": "^6.0.3"
44
32
  },
45
33
  "engines": {
46
34
  "node": ">=22.0.0"
35
+ },
36
+ "keywords": [
37
+ "ai",
38
+ "cli"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "dev": "tsx src/cli.ts",
43
+ "start": "node dist/cli.js"
47
44
  }
48
- }
45
+ }
@@ -1,30 +0,0 @@
1
- import { exitCommand, clearCommand, mcpCommand, compactCommand, exportCommand } from "./builtin.js";
2
- export class CommandRegistry {
3
- commands = new Map();
4
- register(command) {
5
- this.commands.set(command.name, command);
6
- }
7
- schemas() {
8
- return [...this.commands.values()].map((t) => ({
9
- name: t.name,
10
- description: t.description,
11
- }));
12
- }
13
- exists(name) {
14
- return this.commands.has(name);
15
- }
16
- async execute(command, ctx, host) {
17
- const cmd = this.commands.get(command);
18
- if (!cmd) {
19
- host.error(`unknown command: /${command}`);
20
- return;
21
- }
22
- await cmd.execute(ctx, host);
23
- }
24
- }
25
- export function registerBuiltinCommands(commands) {
26
- commands.register(exitCommand);
27
- commands.register({ ...exitCommand, name: "quit" });
28
- for (const t of [clearCommand, mcpCommand, compactCommand, exportCommand])
29
- commands.register(t);
30
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,116 +0,0 @@
1
- import { withAbort } from "../util/async.js";
2
- const STALL_THRESHOLD = 3;
3
- const COMPACT_PROMPT = "Summarize this conversation into a concise context summary. Preserve the user's goal, decisions made, files touched, and current progress. Write the summary in the same language the user used in the conversation. Begin your reply with \"Summary of conversation so far:\".";
4
- export class Agent {
5
- llm;
6
- session;
7
- tools;
8
- constructor(llm, session, tools) {
9
- this.llm = llm;
10
- this.session = session;
11
- this.tools = tools;
12
- }
13
- get contextTokens() {
14
- return this.session.getEstimatedTokens();
15
- }
16
- clear() {
17
- this.session.clear();
18
- }
19
- export() {
20
- return this.session.export();
21
- }
22
- async compact() {
23
- const history = this.session.toLLM().slice(1);
24
- if (history.length === 0)
25
- return;
26
- const request = [
27
- ...history,
28
- { role: "user", content: COMPACT_PROMPT },
29
- ];
30
- const msg = await this.llm.chat(request, []);
31
- this.session.compact(msg.content || "");
32
- }
33
- async run(userInput, onEvent, signal) {
34
- this.session.createCheckpoint();
35
- try {
36
- this.session.add({ role: "user", content: userInput });
37
- await this.loop(onEvent, signal);
38
- }
39
- finally {
40
- this.session.removeCheckpoint();
41
- }
42
- }
43
- async runSkill(skill, onEvent, signal) {
44
- this.session.createCheckpoint();
45
- try {
46
- this.session.add({ role: "skill", name: skill.name, content: skill.prompt });
47
- await this.loop(onEvent, signal);
48
- }
49
- finally {
50
- this.session.removeCheckpoint();
51
- }
52
- }
53
- async loop(onEvent, signal) {
54
- await withAbort(async (aborted) => {
55
- let lastSig = "";
56
- let stall = 0;
57
- while (true) {
58
- let msg;
59
- try {
60
- msg = await this.llm.chat(this.session.toLLM(), this.tools.schemas(), (text) => onEvent?.({ type: "delta", text }), (attempt, max) => onEvent?.({ type: "retry", attempt, max }), (promptTokens, completionTokens) => onEvent?.({ type: "usage", promptTokens, completionTokens }), signal);
61
- }
62
- catch (e) {
63
- if (aborted())
64
- return;
65
- onEvent?.({ type: "error", text: e.message });
66
- return;
67
- }
68
- this.session.add(msg);
69
- if (!msg.tool_calls?.length)
70
- return;
71
- if (aborted())
72
- return;
73
- const sig = msg.tool_calls
74
- .map((c) => `${c.function.name}:${c.function.arguments}`)
75
- .join("|");
76
- stall = sig === lastSig ? stall + 1 : 1;
77
- lastSig = sig;
78
- if (stall >= STALL_THRESHOLD) {
79
- onEvent?.({ type: "error", text: "agent stalled: repeated identical tool calls" });
80
- return;
81
- }
82
- await Promise.all(msg.tool_calls.map(async (call) => {
83
- let args = {};
84
- let argsError = "";
85
- if (call.function.arguments) {
86
- try {
87
- args = JSON.parse(call.function.arguments);
88
- }
89
- catch (e) {
90
- argsError = `Error: invalid arguments: ${e.message}`;
91
- }
92
- }
93
- const summary = this.tools.summarize(call.function.name, args);
94
- onEvent?.({ type: "tool_start", id: call.id, name: call.function.name, summary });
95
- if (aborted())
96
- return;
97
- const result = argsError
98
- ? { content: argsError, isError: true }
99
- : await this.tools.execute(call.function.name, args);
100
- if (aborted())
101
- return;
102
- onEvent?.({ type: "tool_end", id: call.id, name: call.function.name, result: result.content, isError: result.isError });
103
- this.session.add({ role: "tool", tool_call_id: call.id, content: result.content });
104
- }));
105
- if (aborted())
106
- return;
107
- }
108
- }, {
109
- signal,
110
- onAbort: () => {
111
- this.session.restoreCheckpoint();
112
- onEvent?.({ type: "interrupted" });
113
- },
114
- });
115
- }
116
- }
@@ -1,75 +0,0 @@
1
- function estimateTokens(text) {
2
- if (!text)
3
- return 0;
4
- const cjk = (text.match(/[一-龥぀-ヿ가-힯]/g) || []).length;
5
- const words = (text.match(/[a-zA-Z0-9']+/g) || []).length;
6
- return Math.ceil(cjk * 1.6 + words * 1.3 + (text.length - cjk) * 0.3);
7
- }
8
- function messageText(msg) {
9
- const parts = [];
10
- if (typeof msg.content === "string")
11
- parts.push(msg.content);
12
- else if (Array.isArray(msg.content)) {
13
- for (const p of msg.content) {
14
- if (p.type === "text")
15
- parts.push(p.text);
16
- }
17
- }
18
- if ("tool_calls" in msg && msg.tool_calls) {
19
- for (const tc of msg.tool_calls) {
20
- if (tc.function?.name)
21
- parts.push(tc.function.name);
22
- if (tc.function?.arguments)
23
- parts.push(tc.function.arguments);
24
- }
25
- }
26
- return parts.join(" ");
27
- }
28
- export class Session {
29
- system;
30
- messages = [];
31
- estimatedTokens = 0;
32
- checkpoint;
33
- checkpointTokens = 0;
34
- constructor(system) {
35
- this.system = system;
36
- this.messages.push({ role: "system", content: system });
37
- this.estimatedTokens = estimateTokens(system);
38
- }
39
- getEstimatedTokens() {
40
- return this.estimatedTokens;
41
- }
42
- add(msg) {
43
- this.messages.push(msg);
44
- this.estimatedTokens += estimateTokens(messageText(msg));
45
- }
46
- toLLM() {
47
- return this.messages.map((m) => (m.role === "skill" ? { role: "user", name: m.name, content: m.content } : m));
48
- }
49
- export() {
50
- return this.messages.slice(1);
51
- }
52
- clear() {
53
- this.messages = [{ role: "system", content: this.system }];
54
- this.estimatedTokens = estimateTokens(this.system);
55
- }
56
- compact(summary) {
57
- this.messages = [
58
- { role: "system", content: this.system },
59
- { role: "assistant", content: summary },
60
- ];
61
- this.estimatedTokens = estimateTokens(this.system) + estimateTokens(summary);
62
- }
63
- createCheckpoint() {
64
- this.checkpoint = this.messages.slice();
65
- this.checkpointTokens = this.estimatedTokens;
66
- }
67
- restoreCheckpoint() {
68
- this.messages = this.checkpoint.slice();
69
- this.estimatedTokens = this.checkpointTokens;
70
- }
71
- removeCheckpoint() {
72
- this.checkpoint = undefined;
73
- this.checkpointTokens = 0;
74
- }
75
- }
@@ -1,74 +0,0 @@
1
- import OpenAI, { APIConnectionError } from "openai";
2
- import { withRetry } from "../util/async.js";
3
- const MAX_RETRIES = 3;
4
- export class LLMClient {
5
- client;
6
- model;
7
- constructor(config) {
8
- this.client = new OpenAI({
9
- apiKey: config.apiKey,
10
- baseURL: config.baseUrl || undefined,
11
- maxRetries: 0,
12
- });
13
- this.model = config.model;
14
- }
15
- async chat(messages, tools, onDelta, onRetry, onUsage, signal) {
16
- return withRetry(() => this.streamOnce(messages, tools, onDelta, onUsage, signal), {
17
- retries: MAX_RETRIES,
18
- retryable: (e) => e instanceof APIConnectionError,
19
- backoff: (attempt) => 1000 * 2 ** attempt,
20
- onRetry,
21
- signal,
22
- });
23
- }
24
- async streamOnce(messages, tools, onDelta, onUsage, signal) {
25
- let content = "";
26
- const calls = new Map();
27
- const stream = await this.client.chat.completions.create({
28
- model: this.model,
29
- messages,
30
- tools,
31
- stream: true,
32
- stream_options: { include_usage: true },
33
- }, { signal });
34
- for await (const chunk of stream) {
35
- if (chunk.usage) {
36
- onUsage?.(chunk.usage.prompt_tokens ?? 0, chunk.usage.completion_tokens ?? 0);
37
- }
38
- const delta = chunk.choices[0]?.delta;
39
- if (!delta)
40
- continue;
41
- if (delta.content) {
42
- content += delta.content;
43
- onDelta?.(delta.content);
44
- }
45
- if (delta.tool_calls) {
46
- for (const tc of delta.tool_calls) {
47
- let acc = calls.get(tc.index);
48
- if (!acc) {
49
- acc = { id: tc.id ?? "", name: "", arguments: "" };
50
- calls.set(tc.index, acc);
51
- }
52
- if (tc.function?.name)
53
- acc.name += tc.function.name;
54
- if (tc.function?.arguments)
55
- acc.arguments += tc.function.arguments;
56
- }
57
- }
58
- }
59
- const message = {
60
- role: "assistant",
61
- content: content || null,
62
- };
63
- if (calls.size) {
64
- message.tool_calls = [...calls.entries()]
65
- .sort((a, b) => a[0] - b[0])
66
- .map(([, acc]) => ({
67
- id: acc.id,
68
- type: "function",
69
- function: { name: acc.name, arguments: acc.arguments },
70
- }));
71
- }
72
- return message;
73
- }
74
- }
package/dist/llm/types.js DELETED
@@ -1 +0,0 @@
1
- export {};
@@ -1,45 +0,0 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
- import { getPackageInfo } from "../util/package.js";
4
- function getClientINfo() {
5
- const pkginfo = getPackageInfo();
6
- return { name: pkginfo.name, version: pkginfo.version };
7
- }
8
- export class MCPClient {
9
- name;
10
- client = new Client(getClientINfo(), { capabilities: {} });
11
- transport;
12
- connectReject;
13
- constructor(name, config) {
14
- this.name = name;
15
- this.transport = new StdioClientTransport({ ...config, stderr: "ignore" });
16
- }
17
- async connect() {
18
- return new Promise((resolve, reject) => {
19
- this.connectReject = reject;
20
- this.client
21
- .connect(this.transport)
22
- .then(resolve, reject)
23
- .finally(() => {
24
- this.connectReject = undefined;
25
- });
26
- });
27
- }
28
- async listTools() {
29
- return this.client.listTools().then((r) => r.tools);
30
- }
31
- async callTool(name, args) {
32
- return this.client.callTool({ name, arguments: args });
33
- }
34
- kill() {
35
- this.connectReject?.(new Error("aborted"));
36
- this.connectReject = undefined;
37
- const pid = this.transport.pid;
38
- if (pid) {
39
- try {
40
- process.kill(pid, "SIGTERM");
41
- }
42
- catch { }
43
- }
44
- }
45
- }
@@ -1,85 +0,0 @@
1
- import { MCPClient } from "./client.js";
2
- import { withTimeout } from "../util/async.js";
3
- const CONNECT_TIMEOUT = 30_000;
4
- function fixError(text) {
5
- return text.startsWith("Error: ") ? text : `Error: ${text}`;
6
- }
7
- export class MCPServers {
8
- servers = new Map();
9
- pending = new Set();
10
- disposed = false;
11
- errorBuffer = [];
12
- onError;
13
- report(msg) {
14
- if (this.onError)
15
- this.onError(msg);
16
- else
17
- this.errorBuffer.push(msg);
18
- }
19
- flushErrors() {
20
- const buf = this.errorBuffer;
21
- this.errorBuffer = [];
22
- return buf;
23
- }
24
- async connect(mcpServers = {}) {
25
- const tools = [];
26
- await Promise.all(Object.entries(mcpServers).map(async ([name, cfg]) => {
27
- if (this.disposed)
28
- return;
29
- const client = new MCPClient(name, cfg);
30
- this.pending.add(client);
31
- try {
32
- await withTimeout(client.connect(), CONNECT_TIMEOUT);
33
- if (this.disposed) {
34
- client.kill();
35
- return;
36
- }
37
- const mcpTools = await withTimeout(client.listTools(), CONNECT_TIMEOUT);
38
- if (this.disposed) {
39
- client.kill();
40
- return;
41
- }
42
- this.servers.set(name, { status: "connected", client, tools: mcpTools.map((t) => t.name) });
43
- for (const t of mcpTools)
44
- tools.push(this.adapt(name, client, t));
45
- }
46
- catch (e) {
47
- client.kill();
48
- if (!this.disposed) {
49
- this.servers.set(name, { status: "disabled", tools: [] });
50
- this.report(`MCP server "${name}" failed: ${e.message}`);
51
- }
52
- }
53
- finally {
54
- this.pending.delete(client);
55
- }
56
- }));
57
- return tools;
58
- }
59
- adapt(server, client, tool) {
60
- return {
61
- name: `MCP__${server}__${tool.name}`,
62
- description: tool.description ?? `${server} ${tool.name}`,
63
- parameters: tool.inputSchema,
64
- async execute(args) {
65
- const result = await client.callTool(tool.name, args);
66
- const text = result.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
67
- return result.isError
68
- ? { content: fixError(text), isError: true }
69
- : { content: text || "(no output)" };
70
- },
71
- };
72
- }
73
- list() {
74
- return [...this.servers.entries()].map(([name, s]) => ({ name, status: s.status, tools: s.tools }));
75
- }
76
- kill() {
77
- this.disposed = true;
78
- for (const { client } of this.servers.values())
79
- client?.kill();
80
- for (const client of this.pending)
81
- client.kill();
82
- this.servers.clear();
83
- this.pending.clear();
84
- }
85
- }
@@ -1,43 +0,0 @@
1
- import { existsSync, readdirSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { tryReadFileText } from "../util/fs.js";
4
- function parseSkillFile(skillName, skillFile) {
5
- const content = tryReadFileText(skillFile);
6
- if (!content)
7
- return undefined;
8
- const frontMatterRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
9
- const match = content.match(frontMatterRegex);
10
- let name = null;
11
- let description = "";
12
- let prompt = content;
13
- if (match) {
14
- const yamlBody = match[1];
15
- prompt = content.replace(match[0], "").trim();
16
- const nameMatch = yamlBody.match(/^name:\s*(.+)$/m);
17
- if (nameMatch)
18
- name = nameMatch[1].trim();
19
- const descMatch = yamlBody.match(/^description:\s*(.+)$/m);
20
- if (descMatch)
21
- description = descMatch[1].trim();
22
- }
23
- if (!name) {
24
- name = skillName;
25
- }
26
- return { name, description, prompt };
27
- }
28
- export function tryLoadSkills(path) {
29
- if (!existsSync(path))
30
- return undefined;
31
- const skills = [];
32
- for (const entry of readdirSync(path, { withFileTypes: true })) {
33
- if (!entry.isDirectory())
34
- continue;
35
- const skillFile = join(path, entry.name, "SKILL.md");
36
- if (!existsSync(skillFile))
37
- continue;
38
- const skill = parseSkillFile(entry.name, skillFile);
39
- if (skill && skill.prompt)
40
- skills.push(skill);
41
- }
42
- return skills.length > 0 ? skills : undefined;
43
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,39 +0,0 @@
1
- import { readFile, writeFile } from "node:fs/promises";
2
- const DESCRIPTION = [
3
- "Replace the single occurrence of old_string with new_string in a file.",
4
- "old_string must match exactly (including whitespace and indentation) and appear exactly once; read the file first and include enough surrounding context to be unique.",
5
- "For full rewrites prefer FileWrite.",
6
- ].join(" ");
7
- export const fileEditTool = {
8
- name: "FileEdit",
9
- description: DESCRIPTION,
10
- parameters: {
11
- type: "object",
12
- properties: {
13
- path: { type: "string" },
14
- old_string: { type: "string" },
15
- new_string: { type: "string" },
16
- },
17
- required: ["path", "old_string", "new_string"],
18
- },
19
- async execute(args) {
20
- const path = args.path;
21
- const oldStr = args.old_string;
22
- const newStr = args.new_string;
23
- if (!path)
24
- throw new Error("path is required");
25
- if (!oldStr)
26
- throw new Error("old_string is required");
27
- if (newStr === undefined)
28
- throw new Error("new_string is required");
29
- const content = await readFile(path, "utf-8");
30
- const count = content.split(oldStr).length - 1;
31
- if (count === 0)
32
- throw new Error(`old_string not found in ${path}`);
33
- if (count > 1)
34
- throw new Error(`old_string appears ${count} times in ${path}, must be unique`);
35
- await writeFile(path, content.replace(oldStr, newStr), "utf-8");
36
- return `Edited ${path}`;
37
- },
38
- summaryArg: "path",
39
- };