@wanghuimvp/axon 0.0.1 → 0.2.0
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/README.md +108 -56
- package/dist/cli.js +425 -25
- package/package.json +58 -33
package/README.md
CHANGED
|
@@ -1,56 +1,108 @@
|
|
|
1
|
-
# Axon
|
|
2
|
-
|
|
3
|
-
An agentic coding CLI. Axon streams from Anthropic and runs a multi-step tool loop over your codebase — it reads, searches, and reasons across your files to answer a prompt.
|
|
4
|
-
|
|
5
|
-
> Foundation release (`0.0.x`). Non-interactive `axon -p` mode with read-only tools
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm install -g @wanghuimvp/axon
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
##
|
|
14
|
-
|
|
15
|
-
Axon
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
1
|
+
# Axon
|
|
2
|
+
|
|
3
|
+
An agentic coding CLI. Axon streams from Anthropic and runs a multi-step tool loop over your codebase — it reads, searches, and reasons across your files to answer a prompt.
|
|
4
|
+
|
|
5
|
+
> Foundation release (`0.0.x`). Non-interactive `axon -p` mode with read-only tools (always available) and write/edit/shell tools (enabled with `--yolo`). Interactive TUI is on the roadmap.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @wanghuimvp/axon
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Providers
|
|
14
|
+
|
|
15
|
+
Axon speaks to three backends. The OpenAI-compatible provider also drives any
|
|
16
|
+
OpenAI-protocol endpoint (DeepSeek, Qwen, Kimi, Groq, OpenRouter, ollama, LM Studio, …)
|
|
17
|
+
by pointing `baseUrl` at it.
|
|
18
|
+
|
|
19
|
+
| Provider | `provider` value | API key env | Notes |
|
|
20
|
+
|------------|------------------|---------------------|-------|
|
|
21
|
+
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | Claude models |
|
|
22
|
+
| OpenAI | `openai` | `OPENAI_API_KEY` | GPT + any OpenAI-compatible endpoint via `baseUrl` |
|
|
23
|
+
| Gemini | `gemini` | `GEMINI_API_KEY` | Google Gemini models |
|
|
24
|
+
|
|
25
|
+
Set the relevant API key in your environment before running:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# macOS / Linux
|
|
29
|
+
export OPENAI_API_KEY=sk-...
|
|
30
|
+
# Windows (PowerShell)
|
|
31
|
+
$env:OPENAI_API_KEY="sk-..."
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Select a provider/model
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Per run:
|
|
38
|
+
axon --provider openai --model gpt-4.1 -p "explain engine.ts"
|
|
39
|
+
|
|
40
|
+
# Persisted:
|
|
41
|
+
axon config set provider gemini
|
|
42
|
+
axon config set gemini.model gemini-2.5-pro
|
|
43
|
+
axon config get
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Point at an OpenAI-compatible endpoint (e.g. DeepSeek, ollama)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
axon config set provider openai
|
|
50
|
+
axon config set openai.baseUrl https://api.deepseek.com/v1
|
|
51
|
+
axon config set openai.model deepseek-chat
|
|
52
|
+
export OPENAI_API_KEY=sk-... # the endpoint's key
|
|
53
|
+
axon -p "summarize src/"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For a local ollama server:
|
|
57
|
+
```bash
|
|
58
|
+
axon config set openai.baseUrl http://localhost:11434/v1
|
|
59
|
+
axon config set openai.model qwen2.5-coder
|
|
60
|
+
export OPENAI_API_KEY=ollama # ollama ignores the key but the field must be non-empty
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
axon -p "list the TypeScript files in src and explain what engine.ts does"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Axon will stream the model's reasoning, run read-only tools as needed (`read_file`, `list_dir`, `glob`, `grep`), and print the result.
|
|
70
|
+
|
|
71
|
+
### Options
|
|
72
|
+
|
|
73
|
+
- `-p, --print <prompt>` — run one prompt non-interactively and stream the result.
|
|
74
|
+
- `--provider <name>` — select a provider (anthropic, openai, gemini) for this run.
|
|
75
|
+
- `--model <name>` — select a model for this run.
|
|
76
|
+
- `--yolo` — enable the `write_file`/`edit_file`/`shell` tools without prompting (non-interactive). Off by default: these tools are not even offered to the model, so it sticks to read-only inspection.
|
|
77
|
+
- `--version` — print the version.
|
|
78
|
+
|
|
79
|
+
### Changing files
|
|
80
|
+
|
|
81
|
+
Axon can modify your workspace with `write_file`, `edit_file`, and run commands with `shell`. In non-interactive (`-p`) mode these tools are **only available with `--yolo`** — without the flag they are not offered to the model at all (and a permission gate denies them as defense-in-depth).
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
axon --yolo -p "add a CHANGELOG.md with an initial entry, then run the tests"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Read/search tools (`read_file`, `list_dir`, `glob`, `grep`) always run without a prompt. `write_file` and `edit_file` are confined to the project root (path-escape and symlink-escape protected). `shell` is **not** sandboxed — under `--yolo` it runs arbitrary commands and can affect anything your shell can. Only pass `--yolo` to runs you trust.
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
Optional config file at `~/.axon/config.json`:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"provider": "anthropic",
|
|
96
|
+
"providers": {
|
|
97
|
+
"anthropic": { "model": "claude-opus-4-8" },
|
|
98
|
+
"openai": { "model": "gpt-4.1", "baseUrl": "https://api.openai.com/v1" },
|
|
99
|
+
"gemini": { "model": "gemini-2.5-pro" }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
API keys are read from environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`) and never written to disk.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
package/dist/cli.js
CHANGED
|
@@ -10,13 +10,26 @@ var VERSION = "0.0.1";
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
+
var DEFAULT_MODELS = {
|
|
14
|
+
anthropic: "claude-opus-4-8",
|
|
15
|
+
openai: "gpt-4.1",
|
|
16
|
+
gemini: "gemini-2.5-pro"
|
|
17
|
+
};
|
|
13
18
|
var DEFAULTS = {
|
|
14
19
|
provider: "anthropic",
|
|
15
|
-
model: "claude-opus-4-8",
|
|
16
20
|
providers: {
|
|
17
|
-
anthropic: { apiKey: "env:ANTHROPIC_API_KEY" }
|
|
21
|
+
anthropic: { apiKey: "env:ANTHROPIC_API_KEY" },
|
|
22
|
+
openai: { apiKey: "env:OPENAI_API_KEY" },
|
|
23
|
+
gemini: { apiKey: "env:GEMINI_API_KEY" }
|
|
18
24
|
}
|
|
19
25
|
};
|
|
26
|
+
function resolveModel(cfg) {
|
|
27
|
+
const model = cfg.model ?? cfg.providers[cfg.provider]?.model ?? DEFAULT_MODELS[cfg.provider];
|
|
28
|
+
if (!model) {
|
|
29
|
+
throw new Error(`No model configured for provider "${cfg.provider}". Set "model" in ~/.axon/config.json or pass --model.`);
|
|
30
|
+
}
|
|
31
|
+
return model;
|
|
32
|
+
}
|
|
20
33
|
function resolveEnvRefs(cfg) {
|
|
21
34
|
const providers = {};
|
|
22
35
|
for (const [name, p] of Object.entries(cfg.providers)) {
|
|
@@ -34,14 +47,50 @@ function loadConfig() {
|
|
|
34
47
|
}
|
|
35
48
|
const merged = {
|
|
36
49
|
provider: fileCfg.provider ?? DEFAULTS.provider,
|
|
37
|
-
model: fileCfg.model
|
|
50
|
+
model: fileCfg.model,
|
|
38
51
|
providers: { ...DEFAULTS.providers, ...fileCfg.providers ?? {} }
|
|
39
52
|
};
|
|
40
53
|
return resolveEnvRefs(merged);
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
// src/config/configFile.ts
|
|
57
|
+
import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "node:fs";
|
|
58
|
+
import { homedir as homedir2 } from "node:os";
|
|
59
|
+
import { join as join2, dirname } from "node:path";
|
|
60
|
+
function configPath() {
|
|
61
|
+
return join2(homedir2(), ".axon", "config.json");
|
|
62
|
+
}
|
|
63
|
+
function readConfigFile() {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync2(configPath(), "utf8"));
|
|
66
|
+
} catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function setConfigValue(key, value) {
|
|
71
|
+
const cfg = readConfigFile();
|
|
72
|
+
if (key === "provider" || key === "model") {
|
|
73
|
+
cfg[key] = value;
|
|
74
|
+
} else if (key.includes(".")) {
|
|
75
|
+
const [provider, field] = key.split(".", 2);
|
|
76
|
+
const ALLOWED = /* @__PURE__ */ new Set(["baseUrl", "model"]);
|
|
77
|
+
if (!ALLOWED.has(field)) {
|
|
78
|
+
throw new Error(`Cannot set "${field}" via config. Set API keys via the provider's env var (e.g. ANTHROPIC_API_KEY); only baseUrl and model are settable here.`);
|
|
79
|
+
}
|
|
80
|
+
const providers = cfg.providers ??= {};
|
|
81
|
+
(providers[provider] ??= {})[field] = value;
|
|
82
|
+
} else {
|
|
83
|
+
throw new Error(`Unknown config key "${key}". Use "provider", "model", or "<provider>.<baseUrl|model>".`);
|
|
84
|
+
}
|
|
85
|
+
const path = configPath();
|
|
86
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
87
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
// src/providers/registry.ts
|
|
44
91
|
import Anthropic from "@anthropic-ai/sdk";
|
|
92
|
+
import OpenAI from "openai";
|
|
93
|
+
import { GoogleGenAI } from "@google/genai";
|
|
45
94
|
|
|
46
95
|
// src/providers/anthropic.ts
|
|
47
96
|
function mapStop(reason) {
|
|
@@ -131,24 +180,211 @@ var AnthropicProvider = class {
|
|
|
131
180
|
}
|
|
132
181
|
};
|
|
133
182
|
|
|
183
|
+
// src/providers/openai.ts
|
|
184
|
+
function textOf(blocks) {
|
|
185
|
+
return blocks.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
186
|
+
}
|
|
187
|
+
function toOpenAIMessages(system, messages) {
|
|
188
|
+
const out = [{ role: "system", content: system }];
|
|
189
|
+
for (const m of messages) {
|
|
190
|
+
if (m.role === "tool") {
|
|
191
|
+
out.push({ role: "tool", tool_call_id: m.toolCallId, content: m.content });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (m.role === "user") {
|
|
195
|
+
out.push({ role: "user", content: textOf(m.content) });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const toolCalls = m.content.filter((b) => b.type === "tool_call").map((b) => {
|
|
199
|
+
const t = b;
|
|
200
|
+
return { id: t.id, type: "function", function: { name: t.name, arguments: JSON.stringify(t.args ?? {}) } };
|
|
201
|
+
});
|
|
202
|
+
const msg = { role: "assistant", content: textOf(m.content) };
|
|
203
|
+
if (toolCalls.length) msg.tool_calls = toolCalls;
|
|
204
|
+
out.push(msg);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
function toOpenAITools(tools) {
|
|
209
|
+
if (!tools.length) return void 0;
|
|
210
|
+
return tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
|
|
211
|
+
}
|
|
212
|
+
var OpenAIProvider = class {
|
|
213
|
+
constructor(deps) {
|
|
214
|
+
this.deps = deps;
|
|
215
|
+
}
|
|
216
|
+
callCounter = 0;
|
|
217
|
+
async *stream(req) {
|
|
218
|
+
const tools = toOpenAITools(req.tools);
|
|
219
|
+
const stream = await this.deps.client.chat.completions.create({
|
|
220
|
+
model: this.deps.model,
|
|
221
|
+
stream: true,
|
|
222
|
+
messages: toOpenAIMessages(req.system, req.messages),
|
|
223
|
+
...tools ? { tools } : {}
|
|
224
|
+
});
|
|
225
|
+
const calls = /* @__PURE__ */ new Map();
|
|
226
|
+
let finishReason = null;
|
|
227
|
+
for await (const chunk of stream) {
|
|
228
|
+
const choice = chunk.choices?.[0];
|
|
229
|
+
if (!choice) continue;
|
|
230
|
+
const delta = choice.delta ?? {};
|
|
231
|
+
if (typeof delta.content === "string" && delta.content.length) {
|
|
232
|
+
yield { type: "text_delta", text: delta.content };
|
|
233
|
+
}
|
|
234
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
235
|
+
const cur = calls.get(tc.index) ?? { id: "", name: "", args: "" };
|
|
236
|
+
if (tc.id) cur.id = tc.id;
|
|
237
|
+
if (tc.function?.name) cur.name = tc.function.name;
|
|
238
|
+
if (tc.function?.arguments) cur.args += tc.function.arguments;
|
|
239
|
+
calls.set(tc.index, cur);
|
|
240
|
+
}
|
|
241
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
242
|
+
}
|
|
243
|
+
for (const c of [...calls.entries()].sort((a, b) => a[0] - b[0]).map((e) => e[1])) {
|
|
244
|
+
const id = c.id || `openai-call-${this.callCounter++}`;
|
|
245
|
+
let args;
|
|
246
|
+
try {
|
|
247
|
+
args = c.args.trim() ? JSON.parse(c.args) : {};
|
|
248
|
+
} catch (err) {
|
|
249
|
+
throw new Error(`Tool "${c.name}" (id=${id}): invalid tool-call JSON: ${JSON.stringify(c.args)}`, { cause: err });
|
|
250
|
+
}
|
|
251
|
+
yield { type: "tool_call", id, name: c.name, args };
|
|
252
|
+
}
|
|
253
|
+
const stopReason = calls.size ? "tool_use" : finishReason === "length" ? "max_tokens" : "end";
|
|
254
|
+
yield { type: "done", stopReason };
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// src/providers/gemini.ts
|
|
259
|
+
function textOf2(blocks) {
|
|
260
|
+
return blocks.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
261
|
+
}
|
|
262
|
+
function buildIdToName(messages) {
|
|
263
|
+
const map = /* @__PURE__ */ new Map();
|
|
264
|
+
for (const m of messages) {
|
|
265
|
+
if (m.role === "assistant") {
|
|
266
|
+
for (const b of m.content) {
|
|
267
|
+
if (b.type === "tool_call") map.set(b.id, b.name);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return map;
|
|
272
|
+
}
|
|
273
|
+
function toGeminiContents(messages) {
|
|
274
|
+
const idToName = buildIdToName(messages);
|
|
275
|
+
const out = [];
|
|
276
|
+
let i = 0;
|
|
277
|
+
while (i < messages.length) {
|
|
278
|
+
const m = messages[i];
|
|
279
|
+
if (m.role === "tool") {
|
|
280
|
+
const parts2 = [];
|
|
281
|
+
while (i < messages.length && messages[i].role === "tool") {
|
|
282
|
+
const t = messages[i];
|
|
283
|
+
parts2.push({ functionResponse: { name: idToName.get(t.toolCallId) ?? "unknown", response: { result: t.content } } });
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
out.push({ role: "user", parts: parts2 });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (m.role === "user") {
|
|
290
|
+
out.push({ role: "user", parts: [{ text: textOf2(m.content) }] });
|
|
291
|
+
i++;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const parts = [];
|
|
295
|
+
for (const b of m.content) {
|
|
296
|
+
if (b.type === "text") parts.push({ text: b.text });
|
|
297
|
+
else if (b.type === "tool_call") parts.push({ functionCall: { name: b.name, args: b.args } });
|
|
298
|
+
}
|
|
299
|
+
out.push({ role: "model", parts });
|
|
300
|
+
i++;
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
function toGeminiTools(tools) {
|
|
305
|
+
if (!tools.length) return void 0;
|
|
306
|
+
return [{ functionDeclarations: tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })) }];
|
|
307
|
+
}
|
|
308
|
+
var GeminiProvider = class {
|
|
309
|
+
constructor(deps) {
|
|
310
|
+
this.deps = deps;
|
|
311
|
+
}
|
|
312
|
+
callCounter = 0;
|
|
313
|
+
async *stream(req) {
|
|
314
|
+
const stream = await this.deps.client.models.generateContentStream({
|
|
315
|
+
model: this.deps.model,
|
|
316
|
+
contents: toGeminiContents(req.messages),
|
|
317
|
+
config: {
|
|
318
|
+
systemInstruction: req.system,
|
|
319
|
+
tools: toGeminiTools(req.tools)
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
let sawToolCall = false;
|
|
323
|
+
let finishReason;
|
|
324
|
+
for await (const chunk of stream) {
|
|
325
|
+
const cand = chunk.candidates?.[0];
|
|
326
|
+
const parts = cand?.content?.parts ?? [];
|
|
327
|
+
for (const part of parts) {
|
|
328
|
+
if (typeof part.text === "string" && part.text.length) {
|
|
329
|
+
yield { type: "text_delta", text: part.text };
|
|
330
|
+
} else if (part.functionCall) {
|
|
331
|
+
sawToolCall = true;
|
|
332
|
+
yield {
|
|
333
|
+
type: "tool_call",
|
|
334
|
+
id: `gemini-call-${this.callCounter++}`,
|
|
335
|
+
name: part.functionCall.name,
|
|
336
|
+
args: part.functionCall.args ?? {}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (cand?.finishReason) finishReason = cand.finishReason;
|
|
341
|
+
}
|
|
342
|
+
const stopReason = sawToolCall ? "tool_use" : finishReason === "MAX_TOKENS" ? "max_tokens" : "end";
|
|
343
|
+
yield { type: "done", stopReason };
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
134
347
|
// src/providers/registry.ts
|
|
348
|
+
function requireKey(provider, apiKey) {
|
|
349
|
+
if (!apiKey || !apiKey.trim()) {
|
|
350
|
+
throw new Error(`Missing ${provider} API key. Set it via env or ~/.axon/config.json.`);
|
|
351
|
+
}
|
|
352
|
+
return apiKey;
|
|
353
|
+
}
|
|
135
354
|
function createProvider(cfg) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
355
|
+
const pc = cfg.providers[cfg.provider] ?? {};
|
|
356
|
+
switch (cfg.provider) {
|
|
357
|
+
case "anthropic": {
|
|
358
|
+
const apiKey = requireKey("Anthropic", pc.apiKey);
|
|
359
|
+
const model = resolveModel(cfg);
|
|
360
|
+
const client = new Anthropic({ apiKey });
|
|
361
|
+
return new AnthropicProvider({ client, model });
|
|
362
|
+
}
|
|
363
|
+
case "openai": {
|
|
364
|
+
const apiKey = requireKey("OpenAI", pc.apiKey);
|
|
365
|
+
const model = resolveModel(cfg);
|
|
366
|
+
const client = new OpenAI({ apiKey, baseURL: pc.baseUrl });
|
|
367
|
+
return new OpenAIProvider({ client, model });
|
|
368
|
+
}
|
|
369
|
+
case "gemini": {
|
|
370
|
+
const apiKey = requireKey("Gemini", pc.apiKey);
|
|
371
|
+
const model = resolveModel(cfg);
|
|
372
|
+
const client = new GoogleGenAI({ apiKey });
|
|
373
|
+
return new GeminiProvider({ client, model });
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
throw new Error(`Unsupported provider: ${cfg.provider}`);
|
|
141
377
|
}
|
|
142
|
-
throw new Error(`Unsupported provider: ${cfg.provider}`);
|
|
143
378
|
}
|
|
144
379
|
|
|
145
380
|
// src/tools/fs.ts
|
|
146
381
|
import { readFile, readdir } from "node:fs/promises";
|
|
147
|
-
import { join as
|
|
382
|
+
import { join as join3, resolve as resolve2 } from "node:path";
|
|
148
383
|
import fg from "fast-glob";
|
|
149
384
|
|
|
150
385
|
// src/tools/paths.ts
|
|
151
|
-
import { resolve, relative, isAbsolute, sep } from "node:path";
|
|
386
|
+
import { resolve, relative, isAbsolute, sep, dirname as dirname2 } from "node:path";
|
|
387
|
+
import { realpathSync, existsSync } from "node:fs";
|
|
152
388
|
function resolveInside(cwd, p) {
|
|
153
389
|
const root = resolve(cwd);
|
|
154
390
|
const full = isAbsolute(p) ? resolve(p) : resolve(root, p);
|
|
@@ -164,6 +400,20 @@ function assertSafeGlob(pattern) {
|
|
|
164
400
|
throw new Error(`glob pattern escapes project root: ${pattern}`);
|
|
165
401
|
}
|
|
166
402
|
}
|
|
403
|
+
function assertRealInside(cwd, full) {
|
|
404
|
+
const root = realpathSync(resolve(cwd));
|
|
405
|
+
let p = full;
|
|
406
|
+
while (!existsSync(p)) {
|
|
407
|
+
const parent = dirname2(p);
|
|
408
|
+
if (parent === p) throw new Error(`cannot resolve path: ${full}`);
|
|
409
|
+
p = parent;
|
|
410
|
+
}
|
|
411
|
+
const real = realpathSync(p);
|
|
412
|
+
const rel = relative(root, real);
|
|
413
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
|
|
414
|
+
throw new Error(`path escapes project root via symlink: ${full}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
167
417
|
|
|
168
418
|
// src/tools/fs.ts
|
|
169
419
|
function fail(err) {
|
|
@@ -249,7 +499,7 @@ var grepTool = {
|
|
|
249
499
|
const files = await fg(glob, { cwd: root, onlyFiles: true, dot: false });
|
|
250
500
|
const hits = [];
|
|
251
501
|
for (const rel of files) {
|
|
252
|
-
const text = await readFile(
|
|
502
|
+
const text = await readFile(join3(root, rel), "utf8").catch(() => "");
|
|
253
503
|
text.split("\n").forEach((line, i) => {
|
|
254
504
|
if (re.test(line)) hits.push(`${rel}:${i + 1}:${line}`);
|
|
255
505
|
});
|
|
@@ -261,11 +511,129 @@ var grepTool = {
|
|
|
261
511
|
}
|
|
262
512
|
};
|
|
263
513
|
|
|
514
|
+
// src/tools/edit.ts
|
|
515
|
+
import { readFile as readFile2, writeFile, mkdir } from "node:fs/promises";
|
|
516
|
+
import { dirname as dirname3 } from "node:path";
|
|
517
|
+
function fail2(err) {
|
|
518
|
+
return { ok: false, output: err instanceof Error ? err.message : String(err) };
|
|
519
|
+
}
|
|
520
|
+
var writeFileTool = {
|
|
521
|
+
name: "write_file",
|
|
522
|
+
dangerous: true,
|
|
523
|
+
schema: {
|
|
524
|
+
name: "write_file",
|
|
525
|
+
description: "Create or overwrite a file (relative to the project root). Creates parent directories as needed.",
|
|
526
|
+
parameters: {
|
|
527
|
+
type: "object",
|
|
528
|
+
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
529
|
+
required: ["path", "content"]
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
async run(args, ctx) {
|
|
533
|
+
try {
|
|
534
|
+
const { path, content } = args;
|
|
535
|
+
const full = resolveInside(ctx.cwd, path);
|
|
536
|
+
assertRealInside(ctx.cwd, full);
|
|
537
|
+
await mkdir(dirname3(full), { recursive: true });
|
|
538
|
+
await writeFile(full, content, "utf8");
|
|
539
|
+
return { ok: true, output: `wrote ${Buffer.byteLength(content, "utf8")} bytes to ${path}` };
|
|
540
|
+
} catch (err) {
|
|
541
|
+
return fail2(err);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
var editFileTool = {
|
|
546
|
+
name: "edit_file",
|
|
547
|
+
dangerous: true,
|
|
548
|
+
schema: {
|
|
549
|
+
name: "edit_file",
|
|
550
|
+
description: "Replace an exact, unique occurrence of old_string with new_string in a file.",
|
|
551
|
+
parameters: {
|
|
552
|
+
type: "object",
|
|
553
|
+
properties: { path: { type: "string" }, old_string: { type: "string" }, new_string: { type: "string" } },
|
|
554
|
+
required: ["path", "old_string", "new_string"]
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
async run(args, ctx) {
|
|
558
|
+
try {
|
|
559
|
+
const { path, old_string, new_string } = args;
|
|
560
|
+
const full = resolveInside(ctx.cwd, path);
|
|
561
|
+
assertRealInside(ctx.cwd, full);
|
|
562
|
+
if (old_string === "") return { ok: false, output: "old_string must not be empty" };
|
|
563
|
+
const text = await readFile2(full, "utf8");
|
|
564
|
+
const count = text.split(old_string).length - 1;
|
|
565
|
+
if (count === 0) return { ok: false, output: `old_string not found in ${path}` };
|
|
566
|
+
if (count > 1) return { ok: false, output: `old_string matches ${count} occurrences in ${path}; make it unique` };
|
|
567
|
+
const updated = text.split(old_string).join(new_string);
|
|
568
|
+
await writeFile(full, updated, "utf8");
|
|
569
|
+
return { ok: true, output: `edited ${path}` };
|
|
570
|
+
} catch (err) {
|
|
571
|
+
return fail2(err);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// src/tools/shell.ts
|
|
577
|
+
import { exec } from "node:child_process";
|
|
578
|
+
import { promisify } from "node:util";
|
|
579
|
+
var pexec = promisify(exec);
|
|
580
|
+
function combine(stdout, stderr) {
|
|
581
|
+
return [stdout, stderr].filter((s) => s.trim()).join("\n").trim();
|
|
582
|
+
}
|
|
583
|
+
var shellTool = {
|
|
584
|
+
name: "shell",
|
|
585
|
+
dangerous: true,
|
|
586
|
+
schema: {
|
|
587
|
+
name: "shell",
|
|
588
|
+
description: "Run a shell command in the project root. Returns combined stdout+stderr; ok is false on a nonzero exit or timeout.",
|
|
589
|
+
parameters: {
|
|
590
|
+
type: "object",
|
|
591
|
+
properties: { command: { type: "string" } },
|
|
592
|
+
required: ["command"]
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
async run(args, ctx) {
|
|
596
|
+
const { command } = args;
|
|
597
|
+
try {
|
|
598
|
+
const { stdout, stderr } = await pexec(command, {
|
|
599
|
+
cwd: ctx.cwd,
|
|
600
|
+
timeout: 12e4,
|
|
601
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
602
|
+
windowsHide: true
|
|
603
|
+
});
|
|
604
|
+
return { ok: true, output: combine(stdout, stderr) || "(no output)" };
|
|
605
|
+
} catch (err) {
|
|
606
|
+
const e = err;
|
|
607
|
+
const body = combine(e.stdout ?? "", e.stderr ?? "");
|
|
608
|
+
const status = e.killed && e.code == null ? "timed out" : e.killed ? "killed by signal" : `exit ${e.code ?? "?"}`;
|
|
609
|
+
return { ok: false, output: `[${status}] ${body || e.message}`.trim() };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
|
|
264
614
|
// src/tools/registry.ts
|
|
265
|
-
function
|
|
266
|
-
const tools = [readFileTool, listDirTool, globTool, grepTool];
|
|
615
|
+
function toMap(tools) {
|
|
267
616
|
return new Map(tools.map((t) => [t.name, t]));
|
|
268
617
|
}
|
|
618
|
+
function buildReadOnlyTools() {
|
|
619
|
+
return toMap([readFileTool, listDirTool, globTool, grepTool]);
|
|
620
|
+
}
|
|
621
|
+
function buildMutatingTools() {
|
|
622
|
+
return toMap([writeFileTool, editFileTool, shellTool]);
|
|
623
|
+
}
|
|
624
|
+
function buildAllTools() {
|
|
625
|
+
return toMap([...buildReadOnlyTools().values(), ...buildMutatingTools().values()]);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/permission/gate.ts
|
|
629
|
+
var denyGate = async (req) => ({
|
|
630
|
+
allow: false,
|
|
631
|
+
reason: `permission denied: "${req.name}" is a write/exec action and non-interactive mode blocks it. Re-run with --yolo to allow.`
|
|
632
|
+
});
|
|
633
|
+
var allowAllGate = async () => ({
|
|
634
|
+
allow: true,
|
|
635
|
+
reason: "allowed (--yolo)"
|
|
636
|
+
});
|
|
269
637
|
|
|
270
638
|
// src/core/conversation.ts
|
|
271
639
|
var Conversation = class {
|
|
@@ -344,6 +712,15 @@ var Engine = class {
|
|
|
344
712
|
this.convo.pushToolResult(call.id, `unknown tool: ${call.name}`);
|
|
345
713
|
continue;
|
|
346
714
|
}
|
|
715
|
+
if (tool.dangerous) {
|
|
716
|
+
const gate = this.deps.gate ?? denyGate;
|
|
717
|
+
const verdict = await gate({ id: call.id, name: call.name, args: call.args });
|
|
718
|
+
if (!verdict.allow) {
|
|
719
|
+
this.emit({ type: "tool_end", id: call.id, ok: false, output: verdict.reason });
|
|
720
|
+
this.convo.pushToolResult(call.id, verdict.reason);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
347
724
|
let result;
|
|
348
725
|
try {
|
|
349
726
|
result = await tool.run(call.args, { cwd: this.deps.cwd });
|
|
@@ -397,25 +774,48 @@ function truncate(s, max = 500) {
|
|
|
397
774
|
}
|
|
398
775
|
|
|
399
776
|
// src/cli.ts
|
|
400
|
-
var
|
|
777
|
+
var READONLY_SYSTEM = `You are Axon, an agentic coding assistant. Use the read-only tools \u2014 read_file, list_dir, glob, grep \u2014 to inspect the project and answer precisely. When done, stop calling tools.`;
|
|
778
|
+
var YOLO_SYSTEM = `You are Axon, an agentic coding assistant. Use the provided tools to inspect AND modify the project: read_file, list_dir, glob, grep (read-only), and write_file, edit_file, shell (these change the workspace). Prefer edit_file for surgical changes. When done, stop calling tools.`;
|
|
401
779
|
var program = new Command();
|
|
402
|
-
program.name("axon").version(VERSION).option("-p, --print <prompt>", "run one prompt non-interactively and stream the result");
|
|
780
|
+
program.name("axon").version(VERSION).option("-p, --print <prompt>", "run one prompt non-interactively and stream the result").option("--provider <name>", "override the provider for this run (anthropic | openai | gemini)").option("--model <name>", "override the model for this run").option("--yolo", "allow write/edit/shell tools without prompting (non-interactive)");
|
|
781
|
+
program.command("config").argument("<action>", "get | set").argument("[key]", "config key (provider | model | <provider>.<baseUrl|model>)").argument("[value]", "value to set").action((action, key, value) => {
|
|
782
|
+
if (action === "get") {
|
|
783
|
+
process.stdout.write(JSON.stringify(readConfigFile(), null, 2) + "\n");
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (action === "set") {
|
|
787
|
+
if (!key || value === void 0) throw new Error("Usage: axon config set <key> <value>");
|
|
788
|
+
setConfigValue(key, value);
|
|
789
|
+
process.stdout.write(`set ${key} = ${value}
|
|
790
|
+
`);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
throw new Error(`Unknown config action "${action}". Use get or set.`);
|
|
794
|
+
});
|
|
795
|
+
program.action(() => {
|
|
796
|
+
const opts = program.opts();
|
|
797
|
+
main(opts).catch((err) => {
|
|
798
|
+
process.stderr.write(`\u{1F4A5} ${err instanceof Error ? err.message : String(err)}
|
|
799
|
+
`);
|
|
800
|
+
process.exit(1);
|
|
801
|
+
});
|
|
802
|
+
});
|
|
403
803
|
program.parse();
|
|
404
|
-
|
|
405
|
-
async function main() {
|
|
804
|
+
async function main(opts) {
|
|
406
805
|
if (!opts.print) {
|
|
407
806
|
process.stdout.write('Interactive TUI not built yet \u2014 use: axon -p "your prompt"\n');
|
|
408
807
|
return;
|
|
409
808
|
}
|
|
410
809
|
const cfg = loadConfig();
|
|
810
|
+
if (opts.provider) cfg.provider = opts.provider;
|
|
811
|
+
if (opts.model) cfg.model = opts.model;
|
|
411
812
|
const provider = createProvider(cfg);
|
|
412
|
-
const tools = buildReadOnlyTools();
|
|
413
|
-
const
|
|
813
|
+
const tools = opts.yolo ? buildAllTools() : buildReadOnlyTools();
|
|
814
|
+
const gate = opts.yolo ? allowAllGate : denyGate;
|
|
815
|
+
const system = opts.yolo ? YOLO_SYSTEM : READONLY_SYSTEM;
|
|
816
|
+
const engine = new Engine({ provider, tools, system, cwd: process.cwd(), gate });
|
|
414
817
|
printRunner(engine, (s) => process.stdout.write(s));
|
|
818
|
+
process.stderr.write(`[axon: ${cfg.provider} / ${resolveModel(cfg)}${opts.yolo ? " / yolo" : ""}]
|
|
819
|
+
`);
|
|
415
820
|
await engine.submit(opts.print);
|
|
416
821
|
}
|
|
417
|
-
main().catch((err) => {
|
|
418
|
-
process.stderr.write(`\u{1F4A5} ${err instanceof Error ? err.message : String(err)}
|
|
419
|
-
`);
|
|
420
|
-
process.exit(1);
|
|
421
|
-
});
|
package/package.json
CHANGED
|
@@ -1,33 +1,58 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@wanghuimvp/axon",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Axon —
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@wanghuimvp/axon",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Axon — a multi-provider agentic coding CLI (Anthropic, OpenAI + OpenAI-compatible endpoints, Gemini). Runs a multi-step tool loop over your codebase.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"axon": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cli",
|
|
18
|
+
"agent",
|
|
19
|
+
"agentic",
|
|
20
|
+
"coding",
|
|
21
|
+
"anthropic",
|
|
22
|
+
"claude",
|
|
23
|
+
"ai",
|
|
24
|
+
"assistant",
|
|
25
|
+
"axon"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Wade-DevCode/axon.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/Wade-DevCode/axon#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/Wade-DevCode/axon/issues"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "tsx src/cli.ts",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --packages=external",
|
|
42
|
+
"prepublishOnly": "npm test && npm run build"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@anthropic-ai/sdk": "^0.40.0",
|
|
46
|
+
"@google/genai": "^1.52.0",
|
|
47
|
+
"commander": "^12.0.0",
|
|
48
|
+
"fast-glob": "^3.3.0",
|
|
49
|
+
"openai": "^4.104.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^20.0.0",
|
|
53
|
+
"esbuild": "^0.23.0",
|
|
54
|
+
"tsx": "^4.0.0",
|
|
55
|
+
"typescript": "^5.5.0",
|
|
56
|
+
"vitest": "^2.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|