@zhongqian97-code/ecode 0.0.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.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/index.js +286 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zhongqian97-code
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# ecode
|
|
2
|
+
|
|
3
|
+
A minimal [Claude Code](https://claude.ai/code) clone — REPL interface with streaming LLM responses and bash tool calling.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @zhongqian-code/ecode
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Set your API key (OpenAI-compatible endpoint)
|
|
15
|
+
export ECODE_API_KEY=sk-...
|
|
16
|
+
|
|
17
|
+
# Start the REPL
|
|
18
|
+
ecode
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Type your message, press Enter. Type `exit` to quit.
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
Priority: **env vars > config file > defaults**
|
|
26
|
+
|
|
27
|
+
### Environment variables
|
|
28
|
+
|
|
29
|
+
| Variable | Default | Description |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `ECODE_API_KEY` | *(required)* | API key |
|
|
32
|
+
| `ECODE_BASE_URL` | `https://api.openai.com/v1` | Base URL (any OpenAI-compatible endpoint) |
|
|
33
|
+
| `ECODE_MODEL` | `gpt-4o` | Model name |
|
|
34
|
+
|
|
35
|
+
### Config file
|
|
36
|
+
|
|
37
|
+
`~/.ecode/config.json`
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"apiKey": "sk-...",
|
|
42
|
+
"baseUrl": "https://api.openai.com/v1",
|
|
43
|
+
"model": "gpt-4o"
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Using with other providers
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# DeepSeek
|
|
51
|
+
export ECODE_BASE_URL=https://api.deepseek.com/v1
|
|
52
|
+
export ECODE_API_KEY=sk-...
|
|
53
|
+
export ECODE_MODEL=deepseek-chat
|
|
54
|
+
ecode
|
|
55
|
+
|
|
56
|
+
# Local Ollama
|
|
57
|
+
export ECODE_BASE_URL=http://localhost:11434/v1
|
|
58
|
+
export ECODE_API_KEY=ollama
|
|
59
|
+
export ECODE_MODEL=llama3
|
|
60
|
+
ecode
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Bash tool calling
|
|
64
|
+
|
|
65
|
+
ecode gives the LLM access to a bash tool. Commands are classified into three tiers:
|
|
66
|
+
|
|
67
|
+
| Tier | Examples | Behavior |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| **Allow** | `ls`, `cat`, `pwd`, `echo` | Auto-execute, no prompt |
|
|
70
|
+
| **Normal** | `git status`, `npm install` | Single confirmation |
|
|
71
|
+
| **Danger** | `rm -rf`, `sudo`, `chmod` | Double confirmation |
|
|
72
|
+
|
|
73
|
+
## Requirements
|
|
74
|
+
|
|
75
|
+
- Node.js >= 18
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/config.ts
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
var DEFAULTS = {
|
|
8
|
+
baseUrl: "https://api.openai.com/v1",
|
|
9
|
+
apiKey: "",
|
|
10
|
+
model: "gpt-4o"
|
|
11
|
+
};
|
|
12
|
+
function loadConfig() {
|
|
13
|
+
const configPath = join(homedir(), ".ecode", "config.json");
|
|
14
|
+
let fileConfig = {};
|
|
15
|
+
if (existsSync(configPath)) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
18
|
+
fileConfig = JSON.parse(raw);
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
baseUrl: process.env.ECODE_BASE_URL ?? fileConfig.baseUrl ?? DEFAULTS.baseUrl,
|
|
24
|
+
apiKey: process.env.ECODE_API_KEY ?? fileConfig.apiKey ?? DEFAULTS.apiKey,
|
|
25
|
+
model: process.env.ECODE_MODEL ?? fileConfig.model ?? DEFAULTS.model
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/repl.ts
|
|
30
|
+
import * as readline from "readline/promises";
|
|
31
|
+
|
|
32
|
+
// src/safety.ts
|
|
33
|
+
var ALLOWLIST = [
|
|
34
|
+
"ls",
|
|
35
|
+
"cat",
|
|
36
|
+
"pwd",
|
|
37
|
+
"echo",
|
|
38
|
+
"head",
|
|
39
|
+
"tail",
|
|
40
|
+
"wc",
|
|
41
|
+
"date",
|
|
42
|
+
"whoami",
|
|
43
|
+
"which",
|
|
44
|
+
"env",
|
|
45
|
+
"printenv"
|
|
46
|
+
];
|
|
47
|
+
var DANGER_LIST = [
|
|
48
|
+
"rm -rf",
|
|
49
|
+
"sudo",
|
|
50
|
+
"chmod",
|
|
51
|
+
"chown",
|
|
52
|
+
"mkfs",
|
|
53
|
+
"dd",
|
|
54
|
+
"fdisk",
|
|
55
|
+
"kill",
|
|
56
|
+
"pkill",
|
|
57
|
+
"killall",
|
|
58
|
+
"reboot",
|
|
59
|
+
"shutdown",
|
|
60
|
+
"halt",
|
|
61
|
+
"curl -X DELETE",
|
|
62
|
+
"wget --delete-after"
|
|
63
|
+
];
|
|
64
|
+
function matchesEntry(cmd, entry) {
|
|
65
|
+
return cmd === entry || cmd.startsWith(entry + " ");
|
|
66
|
+
}
|
|
67
|
+
function classifyCommand(cmd) {
|
|
68
|
+
const trimmed = cmd.trim();
|
|
69
|
+
for (const entry of DANGER_LIST) {
|
|
70
|
+
if (matchesEntry(trimmed, entry)) return "danger";
|
|
71
|
+
}
|
|
72
|
+
for (const entry of ALLOWLIST) {
|
|
73
|
+
if (matchesEntry(trimmed, entry)) return "allow";
|
|
74
|
+
}
|
|
75
|
+
return "normal";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/llm.ts
|
|
79
|
+
import OpenAI from "openai";
|
|
80
|
+
function createLLMClient(config2) {
|
|
81
|
+
const openai = new OpenAI({
|
|
82
|
+
baseURL: config2.baseUrl,
|
|
83
|
+
apiKey: config2.apiKey
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
stream(messages, tools) {
|
|
87
|
+
return {
|
|
88
|
+
[Symbol.asyncIterator]: async function* () {
|
|
89
|
+
const requestParams = {
|
|
90
|
+
model: config2.model,
|
|
91
|
+
messages,
|
|
92
|
+
stream: true
|
|
93
|
+
};
|
|
94
|
+
if (tools && tools.length > 0) {
|
|
95
|
+
requestParams.tools = tools;
|
|
96
|
+
}
|
|
97
|
+
const response = await openai.chat.completions.create(requestParams);
|
|
98
|
+
const tcAccumulator = /* @__PURE__ */ new Map();
|
|
99
|
+
for await (const chunk of response) {
|
|
100
|
+
const choice = chunk.choices[0];
|
|
101
|
+
if (!choice) continue;
|
|
102
|
+
const deltaToolCalls = choice.delta.tool_calls;
|
|
103
|
+
if (deltaToolCalls) {
|
|
104
|
+
for (const tc of deltaToolCalls) {
|
|
105
|
+
if (!tcAccumulator.has(tc.index)) {
|
|
106
|
+
tcAccumulator.set(tc.index, {
|
|
107
|
+
id: tc.id ?? "",
|
|
108
|
+
name: tc.function?.name ?? "",
|
|
109
|
+
arguments: ""
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const existing = tcAccumulator.get(tc.index);
|
|
113
|
+
if (tc.id) existing.id = tc.id;
|
|
114
|
+
if (tc.function?.name) existing.name = tc.function.name;
|
|
115
|
+
existing.arguments += tc.function?.arguments ?? "";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const isLast = choice.finish_reason !== null;
|
|
119
|
+
if (isLast) {
|
|
120
|
+
yield {
|
|
121
|
+
text: choice.delta.content ?? "",
|
|
122
|
+
done: true,
|
|
123
|
+
finishReason: choice.finish_reason,
|
|
124
|
+
toolCalls: tcAccumulator.size > 0 ? Array.from(tcAccumulator.values()) : void 0
|
|
125
|
+
};
|
|
126
|
+
} else {
|
|
127
|
+
yield {
|
|
128
|
+
text: choice.delta.content ?? "",
|
|
129
|
+
done: false
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/tools/bash.ts
|
|
140
|
+
import { exec } from "child_process";
|
|
141
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
142
|
+
function executeBash(cmd, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
exec(cmd, { timeout: timeoutMs }, (err, result) => {
|
|
145
|
+
const stdout = result?.stdout ?? "";
|
|
146
|
+
const stderr = result?.stderr ?? "";
|
|
147
|
+
if (err) {
|
|
148
|
+
const exitCode = err.code ?? 1;
|
|
149
|
+
resolve({ stdout, stderr, exitCode });
|
|
150
|
+
} else {
|
|
151
|
+
resolve({ stdout, stderr, exitCode: 0 });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/repl.ts
|
|
158
|
+
var SKIP_MESSAGE = "Command skipped by user.";
|
|
159
|
+
var BASH_TOOL = {
|
|
160
|
+
type: "function",
|
|
161
|
+
function: {
|
|
162
|
+
name: "bash",
|
|
163
|
+
description: "Execute a shell command and return its output.",
|
|
164
|
+
parameters: {
|
|
165
|
+
type: "object",
|
|
166
|
+
properties: {
|
|
167
|
+
command: { type: "string", description: "The shell command to run." }
|
|
168
|
+
},
|
|
169
|
+
required: ["command"]
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
async function handleBashTool(command, deps) {
|
|
174
|
+
const { confirm, print } = deps;
|
|
175
|
+
const cls = classifyCommand(command);
|
|
176
|
+
if (cls === "normal") {
|
|
177
|
+
const ok = await confirm(`Execute command: ${command}
|
|
178
|
+
Proceed? (y/n) `);
|
|
179
|
+
if (!ok) return SKIP_MESSAGE;
|
|
180
|
+
} else if (cls === "danger") {
|
|
181
|
+
print(`\u26A0\uFE0F DANGEROUS COMMAND: ${command}`);
|
|
182
|
+
const first = await confirm("Are you sure? (y/n) ");
|
|
183
|
+
if (!first) return SKIP_MESSAGE;
|
|
184
|
+
const second = await confirm(
|
|
185
|
+
"Confirm again \u2014 this is destructive. Continue? (y/n) "
|
|
186
|
+
);
|
|
187
|
+
if (!second) return SKIP_MESSAGE;
|
|
188
|
+
}
|
|
189
|
+
const result = await deps.executeBash(command);
|
|
190
|
+
let output = "";
|
|
191
|
+
if (result.stdout) output += result.stdout;
|
|
192
|
+
if (result.stderr) output += result.stderr;
|
|
193
|
+
if (result.exitCode !== 0) output += `
|
|
194
|
+
[exit code: ${result.exitCode}]`;
|
|
195
|
+
return output || "(no output)";
|
|
196
|
+
}
|
|
197
|
+
async function startRepl(config2) {
|
|
198
|
+
const rl = readline.createInterface({
|
|
199
|
+
input: process.stdin,
|
|
200
|
+
output: process.stdout
|
|
201
|
+
});
|
|
202
|
+
const llm = createLLMClient(config2);
|
|
203
|
+
const messages = [];
|
|
204
|
+
const print = (text) => process.stdout.write(text);
|
|
205
|
+
const confirm = async (prompt) => {
|
|
206
|
+
const answer = await rl.question(prompt);
|
|
207
|
+
return answer.trim().toLowerCase() === "y";
|
|
208
|
+
};
|
|
209
|
+
const deps = { executeBash, confirm, print };
|
|
210
|
+
console.log('ecode \u2014 type "exit" to quit\n');
|
|
211
|
+
while (true) {
|
|
212
|
+
const input = await rl.question("you: ").catch(() => "exit");
|
|
213
|
+
const trimmed = input.trim();
|
|
214
|
+
if (trimmed === "exit" || trimmed === "quit") break;
|
|
215
|
+
if (!trimmed) continue;
|
|
216
|
+
messages.push({ role: "user", content: trimmed });
|
|
217
|
+
let continueLoop = true;
|
|
218
|
+
while (continueLoop) {
|
|
219
|
+
continueLoop = false;
|
|
220
|
+
process.stdout.write("assistant: ");
|
|
221
|
+
let assistantText = "";
|
|
222
|
+
const toolCalls = [];
|
|
223
|
+
for await (const chunk of llm.stream(messages, [BASH_TOOL])) {
|
|
224
|
+
if (chunk.text) {
|
|
225
|
+
process.stdout.write(chunk.text);
|
|
226
|
+
assistantText += chunk.text;
|
|
227
|
+
}
|
|
228
|
+
if (chunk.done && chunk.toolCalls) {
|
|
229
|
+
toolCalls.push(...chunk.toolCalls);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (toolCalls.length > 0) {
|
|
233
|
+
messages.push({
|
|
234
|
+
role: "assistant",
|
|
235
|
+
content: assistantText || null,
|
|
236
|
+
tool_calls: toolCalls.map((tc) => ({
|
|
237
|
+
id: tc.id,
|
|
238
|
+
type: "function",
|
|
239
|
+
function: { name: tc.name, arguments: tc.arguments }
|
|
240
|
+
}))
|
|
241
|
+
});
|
|
242
|
+
for (const tc of toolCalls) {
|
|
243
|
+
if (tc.name === "bash") {
|
|
244
|
+
let args;
|
|
245
|
+
try {
|
|
246
|
+
args = JSON.parse(tc.arguments);
|
|
247
|
+
} catch {
|
|
248
|
+
args = { command: "" };
|
|
249
|
+
}
|
|
250
|
+
print(`
|
|
251
|
+
[bash] ${args.command}
|
|
252
|
+
`);
|
|
253
|
+
const toolResult = await handleBashTool(args.command, deps);
|
|
254
|
+
print(toolResult + "\n");
|
|
255
|
+
messages.push({
|
|
256
|
+
role: "tool",
|
|
257
|
+
tool_call_id: tc.id,
|
|
258
|
+
content: toolResult
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
continueLoop = true;
|
|
263
|
+
} else {
|
|
264
|
+
process.stdout.write("\n");
|
|
265
|
+
if (assistantText) {
|
|
266
|
+
messages.push({ role: "assistant", content: assistantText });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
rl.close();
|
|
272
|
+
console.log("\nBye!");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/index.ts
|
|
276
|
+
var config = loadConfig();
|
|
277
|
+
if (!config.apiKey) {
|
|
278
|
+
console.error(
|
|
279
|
+
"Error: no API key configured.\nSet ECODE_API_KEY or add apiKey to ~/.ecode/config.json"
|
|
280
|
+
);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
startRepl(config).catch((err) => {
|
|
284
|
+
console.error("Fatal error:", err);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhongqian97-code/ecode",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A minimal Claude Code clone with REPL interface and bash tool calling",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "zhongqian97-code",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"cli",
|
|
10
|
+
"repl",
|
|
11
|
+
"llm",
|
|
12
|
+
"openai",
|
|
13
|
+
"claude",
|
|
14
|
+
"ai",
|
|
15
|
+
"tool-calling",
|
|
16
|
+
"bash"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/zhongqian97-code/ecode.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/zhongqian97-code/ecode#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/zhongqian97-code/ecode/issues"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"ecode": "dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"dev": "tsx src/index.ts",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"openai": "^4.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^18.0.0",
|
|
45
|
+
"tsup": "^8.0.0",
|
|
46
|
+
"tsx": "^4.0.0",
|
|
47
|
+
"typescript": "^5.0.0",
|
|
48
|
+
"vitest": "^2.0.0"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|