@ai-zen/air 0.1.9 → 0.2.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/README.md +61 -44
- package/README.zh.md +97 -0
- package/dist/agent-factory.js +1 -1
- package/dist/agent-factory.js.map +1 -1
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +241 -228
- package/dist/agent-runtime.js.map +1 -1
- package/dist/chat/commands/back.d.ts +3 -0
- package/dist/chat/commands/back.d.ts.map +1 -0
- package/dist/chat/commands/back.js +95 -0
- package/dist/chat/commands/back.js.map +1 -0
- package/dist/chat/commands/editor.d.ts +3 -0
- package/dist/chat/commands/editor.d.ts.map +1 -0
- package/dist/chat/commands/editor.js +20 -0
- package/dist/chat/commands/editor.js.map +1 -0
- package/dist/chat/commands/exit.d.ts +3 -0
- package/dist/chat/commands/exit.d.ts.map +1 -0
- package/dist/chat/commands/exit.js +5 -0
- package/dist/chat/commands/exit.js.map +1 -0
- package/dist/chat/commands/help.d.ts +2 -0
- package/dist/chat/commands/help.d.ts.map +1 -0
- package/dist/chat/commands/help.js +4 -0
- package/dist/chat/commands/help.js.map +1 -0
- package/dist/chat/commands/load.d.ts +3 -0
- package/dist/chat/commands/load.d.ts.map +1 -0
- package/dist/chat/commands/load.js +39 -0
- package/dist/chat/commands/load.js.map +1 -0
- package/dist/chat/commands/message.d.ts +5 -0
- package/dist/chat/commands/message.d.ts.map +1 -0
- package/dist/chat/commands/message.js +53 -0
- package/dist/chat/commands/message.js.map +1 -0
- package/dist/chat/commands/new.d.ts +3 -0
- package/dist/chat/commands/new.d.ts.map +1 -0
- package/dist/chat/commands/new.js +6 -0
- package/dist/chat/commands/new.js.map +1 -0
- package/dist/chat/commands/save.d.ts +3 -0
- package/dist/chat/commands/save.d.ts.map +1 -0
- package/dist/chat/commands/save.js +5 -0
- package/dist/chat/commands/save.js.map +1 -0
- package/dist/chat/message.d.ts +3 -0
- package/dist/chat/message.d.ts.map +1 -0
- package/dist/chat/message.js +31 -0
- package/dist/chat/message.js.map +1 -0
- package/dist/chat/print.d.ts +3 -0
- package/dist/chat/print.d.ts.map +1 -0
- package/dist/chat/print.js +24 -0
- package/dist/chat/print.js.map +1 -0
- package/dist/chat/runtime.d.ts +2 -0
- package/dist/chat/runtime.d.ts.map +1 -0
- package/dist/chat/runtime.js +47 -0
- package/dist/chat/runtime.js.map +1 -0
- package/dist/chat/session.d.ts +8 -0
- package/dist/chat/session.d.ts.map +1 -0
- package/dist/chat/session.js +77 -0
- package/dist/chat/session.js.map +1 -0
- package/dist/cli.js +19 -3
- package/dist/cli.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/hook.d.ts +3 -0
- package/dist/hook.d.ts.map +1 -0
- package/dist/hook.js +82 -0
- package/dist/hook.js.map +1 -0
- package/dist/session/commands/back.d.ts +3 -0
- package/dist/session/commands/back.d.ts.map +1 -0
- package/dist/session/commands/back.js +95 -0
- package/dist/session/commands/back.js.map +1 -0
- package/dist/session/commands/editor.d.ts +3 -0
- package/dist/session/commands/editor.d.ts.map +1 -0
- package/dist/session/commands/editor.js +20 -0
- package/dist/session/commands/editor.js.map +1 -0
- package/dist/session/commands/exit.d.ts +3 -0
- package/dist/session/commands/exit.d.ts.map +1 -0
- package/dist/session/commands/exit.js +5 -0
- package/dist/session/commands/exit.js.map +1 -0
- package/dist/session/commands/help.d.ts +2 -0
- package/dist/session/commands/help.d.ts.map +1 -0
- package/dist/session/commands/help.js +4 -0
- package/dist/session/commands/help.js.map +1 -0
- package/dist/session/commands/load.d.ts +3 -0
- package/dist/session/commands/load.d.ts.map +1 -0
- package/dist/session/commands/load.js +37 -0
- package/dist/session/commands/load.js.map +1 -0
- package/dist/session/commands/new.d.ts +3 -0
- package/dist/session/commands/new.d.ts.map +1 -0
- package/dist/session/commands/new.js +6 -0
- package/dist/session/commands/new.js.map +1 -0
- package/dist/session/commands/save.d.ts +3 -0
- package/dist/session/commands/save.d.ts.map +1 -0
- package/dist/session/commands/save.js +5 -0
- package/dist/session/commands/save.js.map +1 -0
- package/dist/session/message.d.ts +3 -0
- package/dist/session/message.d.ts.map +1 -0
- package/dist/session/message.js +31 -0
- package/dist/session/message.js.map +1 -0
- package/dist/session/print.d.ts +3 -0
- package/dist/session/print.d.ts.map +1 -0
- package/dist/session/print.js +24 -0
- package/dist/session/print.js.map +1 -0
- package/dist/session/runtime.d.ts +2 -0
- package/dist/session/runtime.d.ts.map +1 -0
- package/dist/session/runtime.js +46 -0
- package/dist/session/runtime.js.map +1 -0
- package/dist/session/shared.d.ts +8 -0
- package/dist/session/shared.d.ts.map +1 -0
- package/dist/session/shared.js +77 -0
- package/dist/session/shared.js.map +1 -0
- package/package.json +12 -3
- package/src/__tests__/e2e.test.ts +187 -0
- package/src/__tests__/session.test.ts +147 -0
- package/src/agent-factory.ts +1 -1
- package/src/cli.ts +19 -4
- package/src/config.ts +1 -1
- package/src/hook.ts +93 -0
- package/src/session/commands/back.ts +90 -0
- package/src/session/commands/editor.ts +18 -0
- package/src/session/commands/exit.ts +6 -0
- package/src/session/commands/help.ts +3 -0
- package/src/session/commands/load.ts +38 -0
- package/src/session/commands/new.ts +7 -0
- package/src/session/commands/save.ts +6 -0
- package/src/session/message.ts +27 -0
- package/src/session/print.ts +27 -0
- package/src/session/runtime.ts +47 -0
- package/src/session/shared.ts +83 -0
- package/src/agent-runtime.ts +0 -265
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/session/shared.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAW,MAAM,qBAAqB,CAAC;AAarD,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,KAAK,CAAC;CACd;AAED,eAAO,MAAM,aAAa,QAcd,CAAC;AAEb,wBAAsB,UAAU,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAI/D;AAgCD,wBAAsB,GAAG,CAAC,GAAG,EAAE,UAAU,iBAaxC"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Message } from "@ai-zen/agents-core";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { saveMessages } from "../config.js";
|
|
4
|
+
import { buildAgent } from "../agent-factory.js";
|
|
5
|
+
import { handleMessage } from "./message.js";
|
|
6
|
+
import { cmdExit } from "./commands/exit.js";
|
|
7
|
+
import { cmdHelp } from "./commands/help.js";
|
|
8
|
+
import { cmdSave } from "./commands/save.js";
|
|
9
|
+
import { cmdNew } from "./commands/new.js";
|
|
10
|
+
import { cmdLoad } from "./commands/load.js";
|
|
11
|
+
import { cmdEditor } from "./commands/editor.js";
|
|
12
|
+
import { cmdBack } from "./commands/back.js";
|
|
13
|
+
export const SYSTEM_PROMPT = [
|
|
14
|
+
"你是一个AI助手,专门帮助用户回答问题和执行任务。请用中文回复。",
|
|
15
|
+
"",
|
|
16
|
+
"## 行为准则",
|
|
17
|
+
"1. 做任何改动之前,必须先跟用户商量,获得明确指示之后再行动。",
|
|
18
|
+
"2. 用户没有明确要求产出文件时,不得自行创建任何文件到项目中。讨论就停留在讨论层面。",
|
|
19
|
+
"3. 区分危险操作:删除文件、覆盖文件、安装卸载软件、修改系统配置、执行耗时任务等属于危险操作。执行前必须评估影响范围,并向用户说明风险,获得用户明确的书面的确认之后再执行。",
|
|
20
|
+
"4. 追责原则:你的每一步操作都应当基于用户的明确指令。如果出了问题,可以从用户说过的话追溯责任——是用户让你做的,用户承担责任。所以你不需要畏手畏脚,只要用户明确说了\u201c做\u201d,你就放心去做。",
|
|
21
|
+
"",
|
|
22
|
+
"## 记忆",
|
|
23
|
+
"你可以使用 shell 工具执行任何命令。如果你有需要长期记住的信息(用户偏好、项目约定、任务进度等),请写入以下位置:",
|
|
24
|
+
"- 全局记忆: ~/.ai-zen/air/temp/*.md",
|
|
25
|
+
"- 项目记忆: $(cwd)/.ai-zen/air/temp/*.md",
|
|
26
|
+
"下次启动时用 shell 读取即可。这是你唯一的记忆方式。",
|
|
27
|
+
].join("\n");
|
|
28
|
+
export async function saveAndNew(ctx) {
|
|
29
|
+
const msgs = [Message.System(SYSTEM_PROMPT)];
|
|
30
|
+
saveMessages(msgs);
|
|
31
|
+
ctx.agent = await buildAgent(msgs);
|
|
32
|
+
}
|
|
33
|
+
async function dispatchCommand(ctx, cmd) {
|
|
34
|
+
switch (cmd) {
|
|
35
|
+
case "/exit":
|
|
36
|
+
case "/quit":
|
|
37
|
+
cmdExit(ctx);
|
|
38
|
+
break;
|
|
39
|
+
case "/save":
|
|
40
|
+
await cmdSave(ctx);
|
|
41
|
+
break;
|
|
42
|
+
case "/new":
|
|
43
|
+
await cmdNew(ctx);
|
|
44
|
+
break;
|
|
45
|
+
case "/load":
|
|
46
|
+
await cmdLoad(ctx);
|
|
47
|
+
break;
|
|
48
|
+
case "/back":
|
|
49
|
+
await cmdBack(ctx);
|
|
50
|
+
break;
|
|
51
|
+
case "/editor":
|
|
52
|
+
await cmdEditor(ctx);
|
|
53
|
+
break;
|
|
54
|
+
case "/help":
|
|
55
|
+
cmdHelp();
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
console.log(`\n❌ 未知命令: ${cmd}\n`);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function ask(ctx) {
|
|
63
|
+
while (true) {
|
|
64
|
+
const { input } = await inquirer.prompt([
|
|
65
|
+
{ type: "input", name: "input", message: "你:" },
|
|
66
|
+
]);
|
|
67
|
+
const t = input.trim();
|
|
68
|
+
if (!t)
|
|
69
|
+
continue;
|
|
70
|
+
if (t.startsWith("/")) {
|
|
71
|
+
await dispatchCommand(ctx, t.toLowerCase());
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
await handleMessage(ctx, t);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=shared.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.js","sourceRoot":"","sources":["../../src/session/shared.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,QAAQ,MAAM,UAAU,CAAC;AAChC,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAC3C,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAM7C,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,kCAAkC;IAClC,EAAE;IACF,SAAS;IACT,kCAAkC;IAClC,6CAA6C;IAC7C,yFAAyF;IACzF,2GAA2G;IAC3G,EAAE;IACF,OAAO;IACP,8DAA8D;IAC9D,iCAAiC;IACjC,sCAAsC;IACtC,+BAA+B;CAChC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,GAAe;IAC9C,MAAM,IAAI,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC;IAC7C,YAAY,CAAC,IAAI,CAAC,CAAC;IACnB,GAAG,CAAC,KAAK,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC;AACrC,CAAC;AAED,KAAK,UAAU,eAAe,CAAC,GAAe,EAAE,GAAW;IACzD,QAAQ,GAAG,EAAE,CAAC;QACZ,KAAK,OAAO,CAAC;QACb,KAAK,OAAO;YACV,OAAO,CAAC,GAAG,CAAC,CAAC;YACb,MAAM;QACR,KAAK,OAAO;YACV,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;YACnB,MAAM;QACR,KAAK,MAAM;YACT,MAAM,MAAM,CAAC,GAAG,CAAC,CAAC;YAClB,MAAM;QACR,KAAK,OAAO;YACV,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;YACnB,MAAM;QACR,KAAK,OAAO;YACV,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;YACnB,MAAM;QACR,KAAK,SAAS;YACZ,MAAM,SAAS,CAAC,GAAG,CAAC,CAAC;YACrB,MAAM;QACR,KAAK,OAAO;YACV,OAAO,EAAE,CAAC;YACV,MAAM;QACR;YACE,OAAO,CAAC,GAAG,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;YAClC,MAAM;IACV,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,GAAG,CAAC,GAAe;IACvC,OAAO,IAAI,EAAE,CAAC;QACZ,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC;YACtC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE;SAChD,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;QACvB,IAAI,CAAC,CAAC;YAAE,SAAS;QACjB,IAAI,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YACtB,MAAM,eAAe,CAAC,GAAG,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;YAC5C,SAAS;QACX,CAAC;QACD,MAAM,aAAa,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-zen/air",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Minimalist AI CLI assistant \u2014 one shell tool, filesystem memory, auto context migration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/agent-runtime.js",
|
|
7
7
|
"bin": {
|
|
@@ -25,5 +25,14 @@
|
|
|
25
25
|
"@types/node": "^24.12.2",
|
|
26
26
|
"typescript": "^5.3.3",
|
|
27
27
|
"vitest": "^4.1.9"
|
|
28
|
-
}
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"ai",
|
|
31
|
+
"cli",
|
|
32
|
+
"assistant",
|
|
33
|
+
"shell",
|
|
34
|
+
"deepseek",
|
|
35
|
+
"terminal",
|
|
36
|
+
"command-line"
|
|
37
|
+
]
|
|
29
38
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, rmSync, mkdirSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
|
|
7
|
+
// ==================== Helpers ====================
|
|
8
|
+
|
|
9
|
+
const CLI = join(process.cwd(), "dist", "cli.js");
|
|
10
|
+
|
|
11
|
+
interface CliResult {
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
exitCode: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function runCli(args: string[], stdinInput?: string, extraEnv?: Record<string, string>): Promise<CliResult> {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const env = {
|
|
20
|
+
...process.env,
|
|
21
|
+
...extraEnv,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const proc = spawn("node", [CLI, ...args], { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
25
|
+
|
|
26
|
+
let stdout = "";
|
|
27
|
+
let stderr = "";
|
|
28
|
+
|
|
29
|
+
proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); });
|
|
30
|
+
proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); });
|
|
31
|
+
|
|
32
|
+
if (stdinInput !== undefined) {
|
|
33
|
+
proc.stdin.write(stdinInput);
|
|
34
|
+
proc.stdin.end();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
proc.on("close", (code) => {
|
|
38
|
+
resolve({ stdout, stderr, exitCode: code ?? -1 });
|
|
39
|
+
});
|
|
40
|
+
proc.on("error", () => {
|
|
41
|
+
resolve({ stdout, stderr, exitCode: -1 });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let tmpDirs: string[] = [];
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
for (const d of tmpDirs) {
|
|
50
|
+
try { rmSync(d, { recursive: true, force: true }); } catch {}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function makeTestDir(): string {
|
|
55
|
+
const dir = join(tmpdir(), `air-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
tmpDirs.push(dir);
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read API key from .env.local
|
|
62
|
+
function getApiKey(): string {
|
|
63
|
+
try {
|
|
64
|
+
const envContent = readFileSync(join(process.cwd(), ".env.local"), "utf-8");
|
|
65
|
+
const match = envContent.match(/DEEPSEEK_API_KEY=(.+)/);
|
|
66
|
+
if (match) return match[1].trim();
|
|
67
|
+
} catch {}
|
|
68
|
+
return process.env.DEEPSEEK_API_KEY || "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const API_KEY = getApiKey();
|
|
72
|
+
|
|
73
|
+
function skipIfNoKey(ctx: any) {
|
|
74
|
+
if (!API_KEY) ctx.skip();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ==================== Tests ====================
|
|
78
|
+
|
|
79
|
+
describe("E2E: CLI 基本功能", () => {
|
|
80
|
+
it("air --help 显示帮助信息", async () => {
|
|
81
|
+
const result = await runCli(["--help"]);
|
|
82
|
+
expect(result.stdout).toContain("Usage:");
|
|
83
|
+
expect(result.stdout).toContain("air");
|
|
84
|
+
expect(result.exitCode).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("air key <key> 设置 API Key", async () => {
|
|
88
|
+
const airDir = makeTestDir();
|
|
89
|
+
const result = await runCli(["key", "sk-test-12345"], undefined, { AIR_DIR: airDir });
|
|
90
|
+
expect(result.stdout).toContain("✅ API Key 已设置");
|
|
91
|
+
|
|
92
|
+
const configFile = join(airDir, "config.json");
|
|
93
|
+
expect(existsSync(configFile)).toBe(true);
|
|
94
|
+
const config = JSON.parse(readFileSync(configFile, "utf-8"));
|
|
95
|
+
expect(config.apiKey).toBe("sk-test-12345");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("air config 显示配置信息", async () => {
|
|
99
|
+
const airDir = makeTestDir();
|
|
100
|
+
await runCli(["key", "sk-test-abcde"], undefined, { AIR_DIR: airDir });
|
|
101
|
+
const result = await runCli(["config"], undefined, { AIR_DIR: airDir });
|
|
102
|
+
expect(result.stdout).toContain("API Key:");
|
|
103
|
+
expect(result.stdout).toContain("bcde");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
describe("E2E: 对话命令", () => {
|
|
109
|
+
it("/save 保存快照", async (ctx) => {
|
|
110
|
+
skipIfNoKey(ctx);
|
|
111
|
+
|
|
112
|
+
const airDir = makeTestDir();
|
|
113
|
+
const env = { AIR_DIR: airDir };
|
|
114
|
+
await runCli(["key", API_KEY], undefined, env);
|
|
115
|
+
|
|
116
|
+
// 先发一条消息建立历史
|
|
117
|
+
await runCli(["测试消息"], "/exit\n", env);
|
|
118
|
+
|
|
119
|
+
// 交互模式下 /save 后 /exit
|
|
120
|
+
const result = await runCli([], "/save\n/exit\n", env);
|
|
121
|
+
expect(result.stdout).toContain("✅ 快照:");
|
|
122
|
+
|
|
123
|
+
// 验证快照文件被创建
|
|
124
|
+
const snapDir = join(airDir, "snapshots");
|
|
125
|
+
const files = readdirSync(snapDir).filter((f) => f.endsWith(".json"));
|
|
126
|
+
expect(files.length).toBeGreaterThanOrEqual(1);
|
|
127
|
+
}, 30000);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("E2E: 真实 API 对话", () => {
|
|
131
|
+
it("air <message> 发送消息并接收真实回复", async (ctx) => {
|
|
132
|
+
skipIfNoKey(ctx);
|
|
133
|
+
|
|
134
|
+
const airDir = makeTestDir();
|
|
135
|
+
const env = { AIR_DIR: airDir };
|
|
136
|
+
|
|
137
|
+
// Set up real key
|
|
138
|
+
await runCli(["key", API_KEY], undefined, env);
|
|
139
|
+
|
|
140
|
+
// Send a simple message, pipe /exit to leave interactive mode
|
|
141
|
+
const result = await runCli(["用一句话介绍你自己"], "/exit\n", env);
|
|
142
|
+
|
|
143
|
+
// Should have AI response
|
|
144
|
+
expect(result.stdout).toContain("🤖 AI:");
|
|
145
|
+
// Should have some actual content (not empty)
|
|
146
|
+
expect(result.stdout.length).toBeGreaterThan(100);
|
|
147
|
+
|
|
148
|
+
// Verify context was saved with user message + AI response
|
|
149
|
+
const contextFile = join(airDir, "context.json");
|
|
150
|
+
expect(existsSync(contextFile)).toBe(true);
|
|
151
|
+
const msgs = JSON.parse(readFileSync(contextFile, "utf-8"));
|
|
152
|
+
expect(msgs.length).toBeGreaterThanOrEqual(2);
|
|
153
|
+
expect(msgs[1].role).toBe("user");
|
|
154
|
+
expect(msgs[1].content).toBe("用一句话介绍你自己");
|
|
155
|
+
|
|
156
|
+
// Verify there's an assistant response
|
|
157
|
+
const assistantMsg = msgs.find((m: any) => m.role === "assistant");
|
|
158
|
+
expect(assistantMsg).toBeTruthy();
|
|
159
|
+
expect(assistantMsg.content.length).toBeGreaterThan(10);
|
|
160
|
+
}, 30000); // 30s timeout for real API
|
|
161
|
+
|
|
162
|
+
it("/new 命令重置对话", async (ctx) => {
|
|
163
|
+
skipIfNoKey(ctx);
|
|
164
|
+
|
|
165
|
+
const airDir = makeTestDir();
|
|
166
|
+
const env = { AIR_DIR: airDir };
|
|
167
|
+
|
|
168
|
+
await runCli(["key", API_KEY], undefined, env);
|
|
169
|
+
|
|
170
|
+
// Send a message to create history
|
|
171
|
+
await runCli(["第一轮消息"], "/exit\n", env);
|
|
172
|
+
|
|
173
|
+
// Verify there's history
|
|
174
|
+
let contextFile = join(airDir, "context.json");
|
|
175
|
+
let msgs = JSON.parse(readFileSync(contextFile, "utf-8"));
|
|
176
|
+
expect(msgs.length).toBeGreaterThanOrEqual(2);
|
|
177
|
+
|
|
178
|
+
// Interactive mode: /new then exit
|
|
179
|
+
const result = await runCli([], "/new\n/exit\n", env);
|
|
180
|
+
expect(result.stdout).toContain("🆕 重新开始");
|
|
181
|
+
|
|
182
|
+
// Verify context was reset
|
|
183
|
+
msgs = JSON.parse(readFileSync(contextFile, "utf-8"));
|
|
184
|
+
expect(msgs.length).toBe(1);
|
|
185
|
+
expect(msgs[0].role).toBe("system");
|
|
186
|
+
}, 30000);
|
|
187
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { SessionCtx } from "../session/shared.js";
|
|
3
|
+
|
|
4
|
+
// Mock all external dependencies
|
|
5
|
+
vi.mock("inquirer", () => ({ default: { prompt: vi.fn() } }));
|
|
6
|
+
vi.mock("../config.js", () => ({
|
|
7
|
+
saveMessages: vi.fn(),
|
|
8
|
+
saveSnapshot: vi.fn(() => "snapshot-123"),
|
|
9
|
+
listSnapshots: vi.fn(() => []),
|
|
10
|
+
loadSnapshot: vi.fn(() => []),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../agent-factory.js", () => ({
|
|
13
|
+
buildAgent: vi.fn(async () => ({
|
|
14
|
+
messages: [],
|
|
15
|
+
events: { on: vi.fn(), off: vi.fn() },
|
|
16
|
+
send: vi.fn(),
|
|
17
|
+
})),
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("../session/print.js", () => ({
|
|
20
|
+
sendAndPrint: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const mockPrompt = vi.mocked((await import("inquirer")).default.prompt);
|
|
24
|
+
const { saveMessages, saveSnapshot, listSnapshots, loadSnapshot } = await import("../config.js");
|
|
25
|
+
const { buildAgent } = await import("../agent-factory.js");
|
|
26
|
+
|
|
27
|
+
function makeCtx(msgs: any[] = []): SessionCtx {
|
|
28
|
+
return { agent: { messages: msgs, events: { on: vi.fn(), off: vi.fn() }, send: vi.fn() } as any };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ==================== shared.ts ====================
|
|
32
|
+
|
|
33
|
+
describe("saveAndNew", () => {
|
|
34
|
+
it("写入 system prompt 并构建 agent", async () => {
|
|
35
|
+
const { saveAndNew } = await import("../session/shared.js");
|
|
36
|
+
const ctx = makeCtx();
|
|
37
|
+
|
|
38
|
+
await saveAndNew(ctx);
|
|
39
|
+
|
|
40
|
+
expect(saveMessages).toHaveBeenCalledWith(
|
|
41
|
+
expect.arrayContaining([expect.objectContaining({ role: "system" })])
|
|
42
|
+
);
|
|
43
|
+
expect(buildAgent).toHaveBeenCalled();
|
|
44
|
+
expect(ctx.agent).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ==================== dispatchCommand 路由 ====================
|
|
49
|
+
|
|
50
|
+
describe("dispatchCommand", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('"/exit" 调用 cmdExit(process.exit)', async () => {
|
|
56
|
+
// Can't easily test process.exit, just verify it doesn't throw
|
|
57
|
+
const { runConversation } = await import("../session/runtime.js");
|
|
58
|
+
// dispatchCommand is internal, tested via routing only
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('"/save" 调用 cmdSave', async () => {
|
|
62
|
+
const { cmdSave } = await import("../session/commands/save.js");
|
|
63
|
+
const ctx = makeCtx([{ role: "system", content: "sys" }]);
|
|
64
|
+
await cmdSave(ctx);
|
|
65
|
+
expect(saveSnapshot).toHaveBeenCalledWith(ctx.agent.messages);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('"/new" 调用 cmdNew 并打印提示', async () => {
|
|
69
|
+
const { cmdNew } = await import("../session/commands/new.js");
|
|
70
|
+
const ctx = makeCtx();
|
|
71
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
72
|
+
await cmdNew(ctx);
|
|
73
|
+
expect(spy).toHaveBeenCalledWith("\n🆕 重新开始\n");
|
|
74
|
+
spy.mockRestore();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('"/help" 打印帮助信息', async () => {
|
|
78
|
+
const { cmdHelp } = await import("../session/commands/help.js");
|
|
79
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
80
|
+
cmdHelp();
|
|
81
|
+
expect(spy).toHaveBeenCalled();
|
|
82
|
+
spy.mockRestore();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ==================== cmdBack ====================
|
|
87
|
+
|
|
88
|
+
describe("cmdBack", () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
vi.clearAllMocks();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("没有消息时提示无可撤回", async () => {
|
|
94
|
+
const { cmdBack } = await import("../session/commands/back.js");
|
|
95
|
+
const ctx = makeCtx([{ role: "system", content: "sys" }]);
|
|
96
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
97
|
+
await cmdBack(ctx);
|
|
98
|
+
expect(spy).toHaveBeenCalledWith("\n❌ 还没有消息可以撤回\n");
|
|
99
|
+
spy.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("取消操作时不修改消息", async () => {
|
|
103
|
+
const { cmdBack } = await import("../session/commands/back.js");
|
|
104
|
+
const msgs = [
|
|
105
|
+
{ role: "system", content: "sys" },
|
|
106
|
+
{ role: "user", content: "你好" },
|
|
107
|
+
];
|
|
108
|
+
const ctx = makeCtx(msgs);
|
|
109
|
+
mockPrompt.mockResolvedValueOnce({ selectedIndex: -1 });
|
|
110
|
+
await cmdBack(ctx);
|
|
111
|
+
expect(ctx.agent.messages.length).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ==================== cmdLoad ====================
|
|
116
|
+
|
|
117
|
+
describe("cmdLoad", () => {
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
vi.clearAllMocks();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("没有快照时提示", async () => {
|
|
123
|
+
const { cmdLoad } = await import("../session/commands/load.js");
|
|
124
|
+
const ctx = makeCtx();
|
|
125
|
+
vi.mocked(listSnapshots).mockReturnValueOnce([]);
|
|
126
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
127
|
+
await cmdLoad(ctx);
|
|
128
|
+
expect(spy).toHaveBeenCalledWith("\n📭 没有可用的快照\n");
|
|
129
|
+
spy.mockRestore();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ==================== cmdEditor ====================
|
|
134
|
+
|
|
135
|
+
describe("cmdEditor", () => {
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
vi.clearAllMocks();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("取消编辑不报错", async () => {
|
|
141
|
+
const { cmdEditor } = await import("../session/commands/editor.js");
|
|
142
|
+
const ctx = makeCtx();
|
|
143
|
+
mockPrompt.mockResolvedValueOnce({ content: "" });
|
|
144
|
+
await cmdEditor(ctx);
|
|
145
|
+
expect(saveMessages).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/agent-factory.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readConfig } from "./config.js";
|
|
|
4
4
|
import { shellTool } from "./tools.js";
|
|
5
5
|
|
|
6
6
|
const MODEL_NAME = "deepseek-v4-flash";
|
|
7
|
-
const API_ENDPOINT = "https://api.deepseek.com/v1";
|
|
7
|
+
const API_ENDPOINT = process.env.AIR_API_ENDPOINT || "https://api.deepseek.com/v1";
|
|
8
8
|
|
|
9
9
|
async function buildModel(apiKey: string) {
|
|
10
10
|
const endpoint = new OpenAI({ openai_endpoint: API_ENDPOINT, api_key: apiKey });
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import { runConversation } from "./
|
|
4
|
+
import { runConversation } from "./session/runtime.js";
|
|
5
|
+
import { installHook, uninstallHook } from "./hook.js";
|
|
5
6
|
import { readConfig, saveConfig } from "./config.js";
|
|
6
7
|
|
|
7
8
|
const { version } = JSON.parse(
|
|
@@ -34,15 +35,29 @@ program
|
|
|
34
35
|
});
|
|
35
36
|
|
|
36
37
|
program
|
|
37
|
-
.
|
|
38
|
-
.
|
|
38
|
+
.command("hook")
|
|
39
|
+
.description("安装/卸载兜底终端钩子(命令不存在时自动转发到 air)")
|
|
40
|
+
.argument("<action>", "install 或 uninstall")
|
|
41
|
+
.action((action: string) => {
|
|
42
|
+
if (action === "install") {
|
|
43
|
+
installHook();
|
|
44
|
+
} else if (action === "uninstall") {
|
|
45
|
+
uninstallHook();
|
|
46
|
+
} else {
|
|
47
|
+
console.error('❌ 未知操作,请使用 install 或 uninstall');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.argument("[message...]", "要发送的消息(不传则进入交互模式)")
|
|
53
|
+
.action(async (message?: string[]) => {
|
|
39
54
|
const config = readConfig();
|
|
40
55
|
if (!config.apiKey) {
|
|
41
56
|
console.error("❌ 请先设置 API Key: air key <your-key>");
|
|
42
57
|
console.error(" 获取 Key: https://platform.deepseek.com/api_keys");
|
|
43
58
|
process.exit(1);
|
|
44
59
|
}
|
|
45
|
-
await runConversation(message);
|
|
60
|
+
await runConversation(message?.join(" "));
|
|
46
61
|
});
|
|
47
62
|
|
|
48
63
|
program.parse(process.argv);
|
package/src/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface Config {
|
|
|
10
10
|
|
|
11
11
|
// ==================== 路径 ====================
|
|
12
12
|
|
|
13
|
-
const AIR_DIR = () => join(homedir(), ".ai-zen", "air");
|
|
13
|
+
const AIR_DIR = () => process.env.AIR_DIR || join(homedir(), ".ai-zen", "air");
|
|
14
14
|
const CONFIG_FILE = () => join(AIR_DIR(), "config.json");
|
|
15
15
|
const CONTEXT_FILE = () => join(AIR_DIR(), "context.json");
|
|
16
16
|
const SNAPSHOTS_DIR = () => join(AIR_DIR(), "snapshots");
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const MARKER_START = "# === air hook (开始) ===";
|
|
6
|
+
const MARKER_END = "# === air hook (结束) ===";
|
|
7
|
+
|
|
8
|
+
function detectShell(): { rcFile: string; hookFn: string } | null {
|
|
9
|
+
const shell = process.env.SHELL || "";
|
|
10
|
+
const home = homedir();
|
|
11
|
+
|
|
12
|
+
if (shell.includes("zsh")) {
|
|
13
|
+
return { rcFile: join(home, ".zshrc"), hookFn: "command_not_found_handler" };
|
|
14
|
+
}
|
|
15
|
+
if (shell.includes("bash")) {
|
|
16
|
+
return { rcFile: join(home, ".bashrc"), hookFn: "command_not_found_handle" };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hookCode(hookFn: string): string {
|
|
22
|
+
return [
|
|
23
|
+
"",
|
|
24
|
+
MARKER_START,
|
|
25
|
+
`${hookFn}() {`,
|
|
26
|
+
' air "$@"',
|
|
27
|
+
"}",
|
|
28
|
+
MARKER_END,
|
|
29
|
+
"",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isInstalled(rcFile: string): boolean {
|
|
34
|
+
if (!existsSync(rcFile)) return false;
|
|
35
|
+
const content = readFileSync(rcFile, "utf-8");
|
|
36
|
+
return content.includes(MARKER_START);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function installHook(): void {
|
|
40
|
+
const shell = detectShell();
|
|
41
|
+
if (!shell) {
|
|
42
|
+
console.error("❌ 不支持的 shell(仅支持 bash / zsh)");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isInstalled(shell.rcFile)) {
|
|
47
|
+
console.log("ℹ️ air hook 已安装,无需重复操作");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
appendFileSync(shell.rcFile, hookCode(shell.hookFn), "utf-8");
|
|
52
|
+
console.log(`✅ air hook 已安装到 ${shell.rcFile}`);
|
|
53
|
+
console.log(` 重启终端或执行 source ${shell.rcFile} 即可生效`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function uninstallHook(): void {
|
|
57
|
+
const shell = detectShell();
|
|
58
|
+
if (!shell) {
|
|
59
|
+
console.error("❌ 不支持的 shell(仅支持 bash / zsh)");
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!existsSync(shell.rcFile)) {
|
|
64
|
+
console.log("ℹ️ air hook 未安装(文件不存在)");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const content = readFileSync(shell.rcFile, "utf-8");
|
|
69
|
+
if (!content.includes(MARKER_START)) {
|
|
70
|
+
console.log("ℹ️ air hook 未安装");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const lines = content.split("\n");
|
|
75
|
+
const newLines: string[] = [];
|
|
76
|
+
let skipping = false;
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (line.trim() === MARKER_START) {
|
|
79
|
+
skipping = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (line.trim() === MARKER_END) {
|
|
83
|
+
skipping = false;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!skipping) {
|
|
87
|
+
newLines.push(line);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
writeFileSync(shell.rcFile, newLines.join("\n"), "utf-8");
|
|
92
|
+
console.log(`✅ air hook 已从 ${shell.rcFile} 卸载`);
|
|
93
|
+
}
|