@movevom/9t 0.1.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.
@@ -0,0 +1,652 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, rmSync, existsSync } from "fs";
2
+ import { join, resolve, dirname, isAbsolute, sep } from "path";
3
+ import { exec, execFile } from "child_process";
4
+ import readline from "readline";
5
+ import { homedir } from "os";
6
+
7
+ const rootDir = resolve(dirname(new URL(import.meta.url).pathname), "..");
8
+ const legacyProviderPath = join(rootDir, "config", "provider.json");
9
+ const workspaceRoot = join(rootDir, "workspace");
10
+
11
+ const toolsMeta = [
12
+ { name: "read", args: "{ path, offset?, limit? }", desc: "读取文件" },
13
+ { name: "edit", args: "{ path, find, replace }", desc: "局部替换" },
14
+ { name: "create", args: '{ path, type?: "file"|"dir", content? }', desc: "创建文件/目录" },
15
+ { name: "delete", args: "{ path }", desc: "删除文件/目录" },
16
+ { name: "ls", args: "{ path? }", desc: "列目录" },
17
+ { name: "grep", args: "{ pattern, path? }", desc: "搜索内容" },
18
+ { name: "glob", args: "{ pattern, path? }", desc: "按模式匹配文件" },
19
+ { name: "execute", args: "{ cmd, cwd? }", desc: "执行命令" },
20
+ { name: "open", args: "{ target }", desc: "打开文件/URL" }
21
+ ];
22
+
23
+ const systemToolsText = toolsMeta.map((t) => `${t.name}: ${t.args}`).join("\n");
24
+ const helpToolsText = toolsMeta.map((t) => `${t.name} ${t.desc}`).join(";");
25
+
26
+ const SYSTEM_PROMPT = `你是 9T 工具代理。你只能输出 JSON,不要输出其它文字。
27
+ 格式:
28
+ {"tool":"create","args":{...}} 或 {"tools":[{"tool":"create","args":{...}}, ...]} 或 {"final":"..."}
29
+ 可用工具与参数:
30
+ ${systemToolsText}
31
+ 所有 path 必须为相对 workspace 的路径,不要使用绝对路径或 ..`;
32
+
33
+ const MAX_STEPS = 8;
34
+
35
+ const testPrompt = "使用9T工具创建文件夹 demo ,在其中创建 test.md,内容为:山高月小,水落石出。";
36
+ const toolHelpText = `可用工具与用途:${helpToolsText}`;
37
+ const aboutText =
38
+ "9T-Movevom 是一个最小可用的 CLI 工具代理,使用 ChatGLM(glm-5)通过工具调用完成文件与命令操作。默认工作区为 Movevom/workspace。";
39
+ const helpText =
40
+ "可用指令:/help /about /tools /? /exit /keystatus。输入自然语言任务可让 9T 调用工具执行。";
41
+
42
+ const getConfigDir = () => {
43
+ const custom = process.env["9T_CONFIG_HOME"];
44
+ if (custom) return custom;
45
+ const home = homedir();
46
+ if (process.platform === "darwin") return join(home, "Library", "Application Support", "9T");
47
+ if (process.platform === "win32") {
48
+ const appData = process.env["APPDATA"] || join(home, "AppData", "Roaming");
49
+ return join(appData, "9T");
50
+ }
51
+ return join(process.env["XDG_CONFIG_HOME"] || join(home, ".config"), "9t");
52
+ };
53
+
54
+ const providerPath = join(getConfigDir(), "provider.json");
55
+
56
+ const defaultProvider = {
57
+ base_url: "https://open.bigmodel.cn/api/anthropic",
58
+ model: "glm-5"
59
+ };
60
+
61
+ const loadProviderConfig = () => {
62
+ if (existsSync(providerPath)) {
63
+ const raw = readFileSync(providerPath, "utf-8");
64
+ return JSON.parse(raw);
65
+ }
66
+ if (existsSync(legacyProviderPath)) {
67
+ const raw = readFileSync(legacyProviderPath, "utf-8");
68
+ return JSON.parse(raw);
69
+ }
70
+ return { ...defaultProvider };
71
+ };
72
+
73
+ const saveProviderConfig = (cfg) => {
74
+ mkdirSync(dirname(providerPath), { recursive: true });
75
+ const safe = { base_url: cfg.base_url, model: cfg.model };
76
+ writeFileSync(providerPath, JSON.stringify(safe, null, 2));
77
+ };
78
+
79
+ const config = loadProviderConfig();
80
+ const baseUrl = String(config.base_url || defaultProvider.base_url).trim().replace(/\/$/, "");
81
+ const model = String(config.model || defaultProvider.model).trim();
82
+ let apiKey = "";
83
+
84
+ if (!baseUrl || !model) {
85
+ console.error("provider.json 需要 base_url、model");
86
+ process.exit(1);
87
+ }
88
+
89
+ const keychainService = "9T-Movevom";
90
+ const keychainAccount = "default";
91
+ const isDarwin = process.platform === "darwin";
92
+ const isWindows = process.platform === "win32";
93
+ const isLinux = process.platform === "linux";
94
+
95
+ const execFileAsync = (cmd, args, input) =>
96
+ new Promise((resolvePromise, reject) => {
97
+ const child = execFile(cmd, args, (err, stdout) => {
98
+ if (err) reject(err);
99
+ else resolvePromise(String(stdout || "").trim());
100
+ });
101
+ if (input != null) {
102
+ child.stdin.write(input);
103
+ child.stdin.end();
104
+ }
105
+ });
106
+
107
+ const getKeychainApiKeyDarwin = async () => {
108
+ try {
109
+ return await execFileAsync("security", [
110
+ "find-generic-password",
111
+ "-a",
112
+ keychainAccount,
113
+ "-s",
114
+ keychainService,
115
+ "-w"
116
+ ]);
117
+ } catch {
118
+ return "";
119
+ }
120
+ };
121
+
122
+ const saveKeychainApiKeyDarwin = async (key) => {
123
+ await execFileAsync("security", [
124
+ "add-generic-password",
125
+ "-a",
126
+ keychainAccount,
127
+ "-s",
128
+ keychainService,
129
+ "-w",
130
+ key,
131
+ "-U"
132
+ ]);
133
+ };
134
+
135
+ const getKeychainApiKeyLinux = async () => {
136
+ try {
137
+ return await execFileAsync("secret-tool", [
138
+ "lookup",
139
+ "service",
140
+ keychainService,
141
+ "account",
142
+ keychainAccount
143
+ ]);
144
+ } catch {
145
+ return "";
146
+ }
147
+ };
148
+
149
+ const saveKeychainApiKeyLinux = async (key) => {
150
+ await execFileAsync(
151
+ "secret-tool",
152
+ ["store", "--label", keychainService, "service", keychainService, "account", keychainAccount],
153
+ key
154
+ );
155
+ };
156
+
157
+ const getKeychainApiKeyWindows = async () => {
158
+ const script = `
159
+ Add-Type -TypeDefinition @"
160
+ using System;
161
+ using System.Runtime.InteropServices;
162
+ public class CredMan {
163
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
164
+ public struct CREDENTIAL {
165
+ public int Flags;
166
+ public int Type;
167
+ public string TargetName;
168
+ public string Comment;
169
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
170
+ public int CredentialBlobSize;
171
+ public IntPtr CredentialBlob;
172
+ public int Persist;
173
+ public int AttributeCount;
174
+ public IntPtr Attributes;
175
+ public string TargetAlias;
176
+ public string UserName;
177
+ }
178
+ [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
179
+ public static extern bool CredRead(string target, int type, int reserved, out IntPtr credPtr);
180
+ [DllImport("advapi32.dll", SetLastError=true)]
181
+ public static extern bool CredFree(IntPtr credPtr);
182
+ }
183
+ "@;
184
+ $target = "${keychainService}";
185
+ $credPtr = [IntPtr]::Zero;
186
+ if ([CredMan]::CredRead($target, 1, 0, [ref]$credPtr)) {
187
+ $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [type]::GetType("CredMan+CREDENTIAL"));
188
+ $password = [Runtime.InteropServices.Marshal]::PtrToStringUni($cred.CredentialBlob, $cred.CredentialBlobSize / 2);
189
+ [CredMan]::CredFree($credPtr);
190
+ Write-Output $password;
191
+ }`;
192
+ try {
193
+ return await execFileAsync("powershell", ["-NoProfile", "-Command", script]);
194
+ } catch {
195
+ return "";
196
+ }
197
+ };
198
+
199
+ const saveKeychainApiKeyWindows = async (key) => {
200
+ const script = `
201
+ Add-Type -TypeDefinition @"
202
+ using System;
203
+ using System.Runtime.InteropServices;
204
+ public class CredMan {
205
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
206
+ public struct CREDENTIAL {
207
+ public int Flags;
208
+ public int Type;
209
+ public string TargetName;
210
+ public string Comment;
211
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
212
+ public int CredentialBlobSize;
213
+ public IntPtr CredentialBlob;
214
+ public int Persist;
215
+ public int AttributeCount;
216
+ public IntPtr Attributes;
217
+ public string TargetAlias;
218
+ public string UserName;
219
+ }
220
+ [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
221
+ public static extern bool CredWrite([In] ref CREDENTIAL userCredential, [In] uint flags);
222
+ }
223
+ "@;
224
+ $secret = [Console]::In.ReadToEnd();
225
+ $secret = $secret.Trim();
226
+ $bytes = [Text.Encoding]::Unicode.GetBytes($secret);
227
+ $cred = New-Object CredMan+CREDENTIAL;
228
+ $cred.Type = 1;
229
+ $cred.TargetName = "${keychainService}";
230
+ $cred.UserName = "${keychainAccount}";
231
+ $cred.CredentialBlobSize = $bytes.Length;
232
+ $cred.CredentialBlob = [Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length);
233
+ [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $cred.CredentialBlob, $bytes.Length);
234
+ $cred.Persist = 2;
235
+ [CredMan]::CredWrite([ref]$cred, 0) | Out-Null;
236
+ [Runtime.InteropServices.Marshal]::FreeHGlobal($cred.CredentialBlob);
237
+ `;
238
+ await execFileAsync("powershell", ["-NoProfile", "-Command", script], key);
239
+ };
240
+
241
+ const getStoredApiKey = async () => {
242
+ if (isDarwin) return await getKeychainApiKeyDarwin();
243
+ if (isLinux) return await getKeychainApiKeyLinux();
244
+ if (isWindows) return await getKeychainApiKeyWindows();
245
+ return "";
246
+ };
247
+
248
+ const saveStoredApiKey = async (key) => {
249
+ if (isDarwin) return await saveKeychainApiKeyDarwin(key);
250
+ if (isLinux) return await saveKeychainApiKeyLinux(key);
251
+ if (isWindows) return await saveKeychainApiKeyWindows(key);
252
+ };
253
+
254
+ const promptHidden = (label) =>
255
+ new Promise((resolvePromise) => {
256
+ const stdin = process.stdin;
257
+ const stdout = process.stdout;
258
+ let input = "";
259
+ const onData = (char) => {
260
+ const c = String(char);
261
+ if (c === "\r" || c === "\n") {
262
+ stdout.write("\n");
263
+ stdin.setRawMode(false);
264
+ stdin.pause();
265
+ stdin.removeListener("data", onData);
266
+ resolvePromise(input.trim());
267
+ return;
268
+ }
269
+ if (c === "\u0003") {
270
+ stdout.write("\n");
271
+ stdin.setRawMode(false);
272
+ stdin.pause();
273
+ stdin.removeListener("data", onData);
274
+ process.exit(1);
275
+ }
276
+ if (c === "\u007f") {
277
+ input = input.slice(0, -1);
278
+ return;
279
+ }
280
+ input += c;
281
+ };
282
+ stdout.write(label);
283
+ stdin.setRawMode(true);
284
+ stdin.resume();
285
+ stdin.on("data", onData);
286
+ });
287
+
288
+ const ensureApiKey = async () => {
289
+ const envKey = String(process.env["9T_API_KEY"] || "").trim();
290
+ if (envKey) return envKey;
291
+ const keychainKey = await getStoredApiKey();
292
+ if (keychainKey) return keychainKey;
293
+ const input = await promptHidden("apikey: ");
294
+ if (!input) {
295
+ throw new Error("api_key 不能为空");
296
+ }
297
+ try {
298
+ await saveStoredApiKey(input);
299
+ } catch (e) {
300
+ console.error("无法安全存储 api_key,请安装系统密钥存储后重试");
301
+ }
302
+ return input;
303
+ };
304
+
305
+ const migratePlaintextApiKey = async () => {
306
+ const fromFile = String(config.api_key || "").trim();
307
+ if (!fromFile) return;
308
+ await saveStoredApiKey(fromFile);
309
+ delete config.api_key;
310
+ saveProviderConfig(config);
311
+ };
312
+
313
+ mkdirSync(workspaceRoot, { recursive: true });
314
+ saveProviderConfig({ base_url: baseUrl, model });
315
+
316
+ const resolveWorkspacePath = (p) => {
317
+ if (!p || typeof p !== "string") throw new Error("path required");
318
+ if (isAbsolute(p)) throw new Error("absolute path not allowed");
319
+ const full = resolve(workspaceRoot, p);
320
+ if (full !== workspaceRoot && !full.startsWith(workspaceRoot + sep)) {
321
+ throw new Error("path escape not allowed");
322
+ }
323
+ return full;
324
+ };
325
+
326
+ const toolRead = (args) => {
327
+ const filePath = resolveWorkspacePath(args.path);
328
+ const content = readFileSync(filePath, "utf-8");
329
+ const lines = content.split(/\r?\n/);
330
+ const offset = Math.max(0, (args.offset || 0) - 1);
331
+ const limit = Math.max(1, args.limit || 200);
332
+ const slice = lines.slice(offset, offset + limit);
333
+ return { content: slice.join("\n"), lines: slice.length, offset: offset + 1 };
334
+ };
335
+
336
+ const toolEdit = (args) => {
337
+ const filePath = resolveWorkspacePath(args.path);
338
+ const content = readFileSync(filePath, "utf-8");
339
+ const find = String(args.find || "");
340
+ const replace = String(args.replace || "");
341
+ if (!find) throw new Error("find required");
342
+ if (!content.includes(find)) throw new Error("find not found");
343
+ const next = content.replace(find, replace);
344
+ writeFileSync(filePath, next);
345
+ return { ok: true };
346
+ };
347
+
348
+ const toolCreate = (args) => {
349
+ const type = args.type === "dir" ? "dir" : "file";
350
+ const target = resolveWorkspacePath(args.path);
351
+ if (type === "dir") {
352
+ mkdirSync(target, { recursive: true });
353
+ return { ok: true, type };
354
+ }
355
+ mkdirSync(dirname(target), { recursive: true });
356
+ const content = args.content == null ? "" : String(args.content);
357
+ writeFileSync(target, content);
358
+ return { ok: true, type };
359
+ };
360
+
361
+ const toolDelete = (args) => {
362
+ const target = resolveWorkspacePath(args.path);
363
+ rmSync(target, { recursive: true, force: true });
364
+ return { ok: true };
365
+ };
366
+
367
+ const toolLs = (args) => {
368
+ const target = resolveWorkspacePath(args?.path || ".");
369
+ const entries = readdirSync(target, { withFileTypes: true }).map((e) => {
370
+ const full = join(target, e.name);
371
+ const st = statSync(full);
372
+ return {
373
+ name: e.name,
374
+ type: e.isDirectory() ? "dir" : "file",
375
+ size: st.size
376
+ };
377
+ });
378
+ return { entries };
379
+ };
380
+
381
+ const walkFiles = (dir, out) => {
382
+ const items = readdirSync(dir, { withFileTypes: true });
383
+ for (const item of items) {
384
+ const full = join(dir, item.name);
385
+ if (item.isDirectory()) {
386
+ walkFiles(full, out);
387
+ } else {
388
+ out.push(full);
389
+ }
390
+ }
391
+ };
392
+
393
+ const toolGrep = (args) => {
394
+ const base = resolveWorkspacePath(args?.path || ".");
395
+ const pattern = String(args.pattern || "");
396
+ if (!pattern) throw new Error("pattern required");
397
+ let regex;
398
+ try {
399
+ regex = new RegExp(pattern, "g");
400
+ } catch {
401
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
402
+ regex = new RegExp(escaped, "g");
403
+ }
404
+ const files = [];
405
+ walkFiles(base, files);
406
+ const matches = [];
407
+ for (const f of files) {
408
+ const content = readFileSync(f, "utf-8");
409
+ const lines = content.split(/\r?\n/);
410
+ lines.forEach((line, i) => {
411
+ if (regex.test(line)) {
412
+ matches.push({ file: f.replace(workspaceRoot + sep, ""), line: i + 1, text: line });
413
+ }
414
+ regex.lastIndex = 0;
415
+ });
416
+ }
417
+ return { matches };
418
+ };
419
+
420
+ const globToRegex = (pattern) => {
421
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
422
+ const regex = escaped
423
+ .replace(/\*\*/g, "###DOUBLESTAR###")
424
+ .replace(/\*/g, "[^/]*")
425
+ .replace(/\?/g, ".");
426
+ const withDouble = regex.replace(/###DOUBLESTAR###/g, ".*");
427
+ return new RegExp("^" + withDouble + "$");
428
+ };
429
+
430
+ const toolGlob = (args) => {
431
+ const base = resolveWorkspacePath(args?.path || ".");
432
+ const pattern = String(args.pattern || "");
433
+ if (!pattern) throw new Error("pattern required");
434
+ const files = [];
435
+ walkFiles(base, files);
436
+ const regex = globToRegex(pattern);
437
+ const matches = files
438
+ .map((f) => f.replace(workspaceRoot + sep, ""))
439
+ .filter((p) => regex.test(p));
440
+ return { matches };
441
+ };
442
+
443
+ const toolExecute = (args) =>
444
+ new Promise((resolvePromise, reject) => {
445
+ const cmd = String(args.cmd || "");
446
+ if (!cmd) return reject(new Error("cmd required"));
447
+ const cwd = args.cwd ? resolveWorkspacePath(args.cwd) : workspaceRoot;
448
+ exec(cmd, { cwd, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
449
+ if (err) {
450
+ resolvePromise({ ok: false, code: err.code || 1, stdout, stderr });
451
+ } else {
452
+ resolvePromise({ ok: true, code: 0, stdout, stderr });
453
+ }
454
+ });
455
+ });
456
+
457
+ const toolOpen = (args) =>
458
+ new Promise((resolvePromise, reject) => {
459
+ const target = String(args.target || "");
460
+ if (!target) return reject(new Error("target required"));
461
+ const platform = process.platform;
462
+ let cmd = "";
463
+ if (platform === "darwin") cmd = `open "${target}"`;
464
+ else if (platform === "win32") cmd = `start "" "${target}"`;
465
+ else cmd = `xdg-open "${target}"`;
466
+ exec(cmd, (err, stdout, stderr) => {
467
+ if (err) resolvePromise({ ok: false, stdout, stderr });
468
+ else resolvePromise({ ok: true, stdout, stderr });
469
+ });
470
+ });
471
+
472
+ const toolMap = {
473
+ read: toolRead,
474
+ edit: toolEdit,
475
+ create: toolCreate,
476
+ delete: toolDelete,
477
+ ls: toolLs,
478
+ grep: toolGrep,
479
+ glob: toolGlob,
480
+ execute: toolExecute,
481
+ open: toolOpen
482
+ };
483
+
484
+ const extractJson = (text) => {
485
+ const cleaned = text.replace(/```json|```/g, "").trim();
486
+ const firstBrace = cleaned.indexOf("{");
487
+ const lastBrace = cleaned.lastIndexOf("}");
488
+ if (firstBrace !== -1 && lastBrace !== -1) {
489
+ const slice = cleaned.slice(firstBrace, lastBrace + 1);
490
+ return JSON.parse(slice);
491
+ }
492
+ const firstBracket = cleaned.indexOf("[");
493
+ const lastBracket = cleaned.lastIndexOf("]");
494
+ if (firstBracket !== -1 && lastBracket !== -1) {
495
+ const slice = cleaned.slice(firstBracket, lastBracket + 1);
496
+ return JSON.parse(slice);
497
+ }
498
+ throw new Error("no json found");
499
+ };
500
+
501
+ const callModel = async (messages) => {
502
+ const res = await fetch(`${baseUrl}/v1/messages`, {
503
+ method: "POST",
504
+ headers: {
505
+ "content-type": "application/json",
506
+ "x-api-key": apiKey,
507
+ "anthropic-version": "2023-06-01"
508
+ },
509
+ body: JSON.stringify({
510
+ model,
511
+ max_tokens: 1024,
512
+ temperature: 0,
513
+ system: SYSTEM_PROMPT,
514
+ messages
515
+ })
516
+ });
517
+ const data = await res.json();
518
+ if (!res.ok) {
519
+ const msg = data?.error?.message || "request failed";
520
+ throw new Error(msg);
521
+ }
522
+ const content = Array.isArray(data.content) ? data.content : [];
523
+ const text = content.map((c) => c.text || "").join("").trim();
524
+ return text;
525
+ };
526
+
527
+ const runPrompt = async (messages, prompt) => {
528
+ messages.push({ role: "user", content: prompt });
529
+ for (let step = 0; step < MAX_STEPS; step++) {
530
+ const text = await callModel(messages);
531
+ messages.push({ role: "assistant", content: text });
532
+ let parsed;
533
+ try {
534
+ parsed = extractJson(text);
535
+ } catch (e) {
536
+ throw new Error(`invalid model json: ${String(e.message || e)}`);
537
+ }
538
+ if (parsed.final) {
539
+ return parsed.final;
540
+ }
541
+ const calls = parsed.tools || (parsed.tool ? [parsed] : []);
542
+ if (!calls.length) {
543
+ throw new Error("no tool calls");
544
+ }
545
+ for (const call of calls) {
546
+ const name = call.tool;
547
+ const args = call.args || {};
548
+ const fn = toolMap[name];
549
+ if (!fn) throw new Error(`unknown tool: ${name}`);
550
+ const result = await fn(args);
551
+ messages.push({
552
+ role: "user",
553
+ content: `TOOL_RESULT ${name} ${JSON.stringify(result)}`
554
+ });
555
+ }
556
+ }
557
+ return "max steps reached";
558
+ };
559
+
560
+ const hasFlag = (args, flag) => args.includes(flag);
561
+
562
+ const isToolHelpQuery = (input) =>
563
+ /工具/.test(input) && /(做什么|用途|能做什么|可以用哪些)/.test(input);
564
+
565
+ const runInteractive = async () => {
566
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
567
+ const messages = [];
568
+ const question = (q) => new Promise((resolve) => rl.question(q, resolve));
569
+ console.log("9T 交互模式,输入 /exit 退出");
570
+ while (true) {
571
+ const input = String(await question("> ")).trim();
572
+ if (!input) continue;
573
+ if (input === "/exit" || input === "/quit") break;
574
+ if (input === "/tools" || input === "/?" || isToolHelpQuery(input)) {
575
+ console.log(toolHelpText);
576
+ continue;
577
+ }
578
+ if (input === "/help") {
579
+ console.log(helpText);
580
+ continue;
581
+ }
582
+ if (input === "/about") {
583
+ console.log(aboutText);
584
+ continue;
585
+ }
586
+ if (input === "/keystatus") {
587
+ const envKey = String(process.env["9T_API_KEY"] || "").trim();
588
+ if (envKey) {
589
+ console.log("api_key 已通过环境变量提供");
590
+ continue;
591
+ }
592
+ const stored = await getStoredApiKey();
593
+ console.log(stored ? "api_key 已安全存储" : "api_key 未存储");
594
+ continue;
595
+ }
596
+ try {
597
+ const out = await runPrompt(messages, input);
598
+ if (out !== undefined && out !== null) console.log(out);
599
+ } catch (e) {
600
+ console.error(String(e?.message || e));
601
+ }
602
+ }
603
+ rl.close();
604
+ };
605
+
606
+ const run = async () => {
607
+ const args = process.argv.slice(2);
608
+ const interactive = hasFlag(args, "--interactive") || hasFlag(args, "-i");
609
+ const testMode = hasFlag(args, "--test");
610
+ const keyStatusMode = hasFlag(args, "--key-status");
611
+ const filteredArgs = args.filter(
612
+ (a) => a !== "--interactive" && a !== "-i" && a !== "--test" && a !== "--key-status"
613
+ );
614
+ await migratePlaintextApiKey();
615
+ if (keyStatusMode) {
616
+ const envKey = String(process.env["9T_API_KEY"] || "").trim();
617
+ if (envKey) {
618
+ console.log("api_key 已通过环境变量提供");
619
+ return;
620
+ }
621
+ const stored = await getStoredApiKey();
622
+ console.log(stored ? "api_key 已安全存储" : "api_key 未存储");
623
+ return;
624
+ }
625
+ apiKey = await ensureApiKey();
626
+
627
+ if (testMode) {
628
+ const messages = [];
629
+ const out = await runPrompt(messages, testPrompt);
630
+ if (out !== undefined && out !== null) console.log(out);
631
+ return;
632
+ }
633
+
634
+ if (interactive || filteredArgs.length === 0) {
635
+ await runInteractive();
636
+ return;
637
+ }
638
+
639
+ const prompt = filteredArgs.join(" ").trim();
640
+ if (!prompt) {
641
+ await runInteractive();
642
+ return;
643
+ }
644
+ const messages = [];
645
+ const out = await runPrompt(messages, prompt);
646
+ if (out !== undefined && out !== null) console.log(out);
647
+ };
648
+
649
+ run().catch((e) => {
650
+ console.error(String(e?.message || e));
651
+ process.exit(1);
652
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "base_url": "https://open.bigmodel.cn/api/anthropic",
3
+ "model": "glm-5",
4
+ "api_key": ""
5
+ }
@@ -0,0 +1,124 @@
1
+ # 001|9T(九工具)独立 AI Agent 设计草案
2
+
3
+ ## 1. 整体思路与背景
4
+
5
+ ### 1.1 背景
6
+ - LLM 很强大,但本质是“对话脑”,缺少直接执行现实任务的手脚
7
+ - 现有工具与系统能力很丰富,需要一个统一入口与治理层
8
+ - 权限管理是核心底线,避免误删、越权与不可逆损失
9
+
10
+ ### 1.2 设计思路
11
+ - 9T 是“工具层与治理层”,负责列出工具、统一协议、权限与路径管理
12
+ - LLM 作为“大脑”负责决策,9T 作为“手脚”负责执行与反馈
13
+ - 安全是第一原则:工具调用前后都要可审计、可拦截、可回放
14
+ - 体验是目标:给人类用户更直观的 UI/UX 与可控的风险提示
15
+
16
+ ### 1.3 你可能遗漏但需要补足的维度
17
+ - **任务生命周期**:任务状态、失败回滚、断点续跑、恢复能力
18
+ - **可观察性**:操作日志、工具调用轨迹、成本与耗时统计
19
+ - **模型治理**:多模型路由、失败回退、限额与速率控制
20
+ - **配置分层**:用户级/项目级/组织级策略覆盖与冲突处理
21
+ - **扩展机制**:插件/技能/外部工具接入的安全边界
22
+ - **多端体验**:CLI、Web、Desktop 之间的一致性与权限同步
23
+
24
+ ## 2. 9T 工具在 mac / Linux / Windows 的实现方式(参考写法)
25
+
26
+ > 目标:工具语义统一,底层按平台适配;优先用内部实现保证一致性,必要时落到系统命令。
27
+
28
+ ### 1.1 read(读取文件)
29
+ - macOS/Linux:`cat <file>`、`sed -n '1,200p' <file>`、`head -n 200 <file>`
30
+ - Windows(PowerShell):`Get-Content <file> -TotalCount 200`
31
+
32
+ ### 1.2 edit(局部编辑/补丁)
33
+ - macOS/Linux:建议使用“统一 diff/patch 规范”由程序应用,必要时 `git apply` 或 `patch -p0`
34
+ - Windows(PowerShell):建议由程序应用补丁,或使用 `git apply`(依赖 Git)
35
+
36
+ ### 1.3 create(创建文件/目录)
37
+ - macOS/Linux:`touch <file>`、`mkdir -p <dir>`
38
+ - Windows(PowerShell):`New-Item -ItemType File <file>`、`New-Item -ItemType Directory <dir>`
39
+
40
+ ### 1.4 delete(删除)
41
+ - macOS/Linux:`rm <file>`、`rm -rf <dir>`
42
+ - Windows(PowerShell):`Remove-Item <file>`、`Remove-Item -Recurse -Force <dir>`
43
+
44
+ ### 1.5 ls(列目录)
45
+ - macOS/Linux:`ls -la <dir>`
46
+ - Windows(PowerShell):`Get-ChildItem <dir>`
47
+
48
+ ### 1.6 grep(内容搜索)
49
+ - macOS/Linux:`rg "<pattern>" <dir>` 或 `grep -R "<pattern>" <dir>`
50
+ - Windows(PowerShell):`Select-String -Path <dir>\* -Pattern "<pattern>" -Recurse`
51
+
52
+ ### 1.7 glob(文件匹配)
53
+ - macOS/Linux:`find <dir> -path "<glob>"` 或 `fd "<pattern>" <dir>`
54
+ - Windows(PowerShell):`Get-ChildItem -Path <dir> -Filter "<glob>" -Recurse`
55
+
56
+ ### 1.8 execute(执行命令)
57
+ - macOS/Linux:`bash -lc "<cmd>"` 或直接执行 `<cmd>`
58
+ - Windows(PowerShell):`powershell -NoProfile -Command "<cmd>"`
59
+
60
+ ### 1.9 open(打开文件/URL)
61
+ - macOS:`open <path|url>`
62
+ - Linux:`xdg-open <path|url>`
63
+ - Windows:`start <path|url>`
64
+
65
+ ## 3. 9T Agent 设计是否有先例、如何借鉴
66
+
67
+ ### 2.1 先例与核心思想
68
+ - **Claude Code**:工具权限通过规则控制(Read/Edit/Bash/WebFetch 等);规则支持 allow/ask/deny,优先级严格;权限设计强调“最小授权 + 可追踪”。[Claude Code Permissions](https://code.claude.com/docs/en/permissions)
69
+ - **Codex CLI**:有“审批模式”,默认允许在工作区内读/写/执行;针对超出范围会请求确认;强调工作区隔离与安全提示。[Codex CLI Features](https://developers.openai.com/codex/cli/features/) [Codex Security](https://developers.openai.com/codex/security/)
70
+ - **Gemini CLI**:提供沙箱配置,默认限制写入范围;执行修改性命令时提示确认;支持多目录工作区配置与权限级别切换。[Gemini CLI Configuration](https://geminicli.com/docs/get-started/configuration/) [Gemini CLI Codelab](https://codelabs.developers.google.com/gemini-cli-hands-on)
71
+
72
+ ### 2.2 9T Agent 的推荐结构
73
+ - **统一工具协议**:工具调用统一 JSON,包含 `tool`、`input`、`cwd`、`timeout`、`risk`、`reason`、`dryRun` 等字段。
74
+ - **权限模型**:三段式策略 `deny > ask > allow`;默认 ask;高风险工具(delete/execute/open)强制二次确认。
75
+ - **工作区隔离(更细化)**:所有读写默认限制在 workspace root;执行类工具的 `cwd` 必须落在 workspace;路径统一标准化(realpath)并拒绝软链逃逸;对 `..`、绝对路径、跨盘符访问进行拦截或强制 ask;workspace 之外仅允许白名单目录(如用户显式添加的 include dirs);对 delete/edit/create 必须进行“目标路径在白名单内”的硬校验。
76
+ - **命令域约束**:execute 仅允许白名单命令前缀或已登记的命令模板;对 `curl/wget` 等网络访问类命令默认 ask 或 deny。
77
+ - **审计日志**:每次工具调用记录输入、输出摘要、耗时、状态码,便于回放与归责。
78
+ - **可插拔工具**:核心 9T 固化,其余扩展走插件或 MCP。
79
+
80
+ ### 2.3 Codex 工作区隔离 vs Gemini 沙箱(差异重点)
81
+ - **Codex**:以“工作区权限”为核心,审批模式决定可自动执行的范围;对超出工作区或需要网络访问的动作提示确认,强调工作区范围内读/写/执行的授权边界。[Codex Security](https://developers.openai.com/codex/security/)
82
+ - **Gemini CLI**:通过沙箱配置文件控制系统层级的限制,默认限制写入到项目目录,支持更严格/自定义沙箱配置,强调 OS 级隔离与可切换的沙箱策略。[Gemini CLI Configuration](https://geminicli.com/docs/get-started/configuration/)
83
+ - **总结**:Codex 更偏“权限审批 + 工作区边界控制”,Gemini 更偏“沙箱策略 + 系统层隔离”,二者可组合成“策略层 + 系统层”双保险。
84
+
85
+ ### 2.4 处理危险操作/链接/恶意代码的硬性机制(除沙箱/隔离外)
86
+ - **硬编码 denylist**:拒绝高风险命令与路径模式(例如 `rm -rf /`、系统目录写入、浏览器/钥匙串路径)。
87
+ - **可执行文件审计**:对新生成的可执行文件要求显式确认;对脚本写入后执行要求二次确认。
88
+ - **网络访问策略**:对外网域名/IP 设白名单;URL 需通过分类器或规则过滤;对下载/执行链路强制“下载 → 扫描 → 允许执行”。
89
+ - **内容扫描**:对写入内容进行静态规则扫描(危险指令片段、已知恶意模式、敏感凭据写入)。
90
+ - **文件类型策略**:对 `.bashrc`、`~/.ssh/*`、系统配置文件等敏感目标默认 deny。
91
+ - **工具分级**:read/ls/grep 低风险自动;edit/create 中风险 ask;delete/execute/open 高风险强制 ask+二次确认。
92
+
93
+ ## 4. 除 9T 外建议新增的能力(最小增量)
94
+ - **move/rename**:独立工具利于权限审计,避免用 execute 隐式完成。
95
+ - **stat**:查询文件/目录元信息(大小/时间/权限)。
96
+ - **diff/patch**:与 edit 配套,便于回滚与审计。
97
+ - **ask_user**:让模型在关键节点询问用户,降低误操作风险。
98
+ - **web_fetch / web_search**:研究与资料收集(可选开关)。
99
+ - **process**:列出/终止进程(用于任务管理)。
100
+
101
+ ## 5. Claude Code / Codex / Gemini CLI 的设计逻辑对比(可借鉴要点)
102
+
103
+ ### 4.1 交互逻辑
104
+ - Claude Code:REPL + /config 等命令,权限通过规则系统配置,工具可被精细化限制。[Claude Code Settings](https://code.claude.com/docs/en/settings)
105
+ - Codex CLI:交互会话 + /permissions 动态调整审批等级;默认在工作区内自由读/写/执行。[Codex CLI Features](https://developers.openai.com/codex/cli/features/)
106
+ - Gemini CLI:命令执行前提示确认;提供沙箱配置与多目录工作区;支持扩展与技能体系。[Gemini CLI Configuration](https://geminicli.com/docs/get-started/configuration/) [Gemini CLI Commands](https://geminicli.com/docs/cli/commands/)
107
+ - 9T 当前交互约定:默认进入交互模式;提供 `/tools`、`/?`、`/help`、`/about`、`/exit` 指令,便于查看工具与帮助
108
+
109
+ ### 4.2 权限与能力边界
110
+ - Claude Code:权限规则适用于 Read/Edit/Bash/WebFetch 等;规则可按路径/模式控制,默认偏保守。[Claude Code Permissions](https://code.claude.com/docs/en/permissions)
111
+ - Codex CLI:审批模式决定自动程度;对超出工作区或网络相关动作提示确认。[Codex Security](https://developers.openai.com/codex/security/)
112
+ - Gemini CLI:默认限制写入范围,并可切换不同沙箱配置;高风险命令会提示确认。[Gemini CLI Configuration](https://geminicli.com/docs/get-started/configuration/)
113
+
114
+ ### 4.3 可扩展性
115
+ - Claude Code:支持子代理与工具白名单/黑名单策略,便于细粒度任务隔离。[Claude Code Sub-agents](https://code.claude.com/docs/en/sub-agents)
116
+ - Codex CLI:支持 MCP 工具接入与扩展服务。[Codex CLI Features](https://developers.openai.com/codex/cli/features/)
117
+ - Gemini CLI:支持扩展与技能管理、工作区管理命令。[Gemini CLI Commands](https://geminicli.com/docs/cli/commands/)
118
+
119
+ ## 6. 9T Agent 的建议落地路线(最小可用)
120
+ 1. 定义工具协议与统一返回格式(成功/失败/输出/错误码)。
121
+ 2. 实现 9T 工具最小版本(read/edit/create/delete/ls/grep/glob/execute/open)。
122
+ 3. 加入权限层:默认 ask + 高风险二次确认 + workspace 隔离。
123
+ 4. 加入审计日志与回放能力。
124
+ 5. 按需扩展 move/rename、stat、diff/patch、process、web。
@@ -0,0 +1,26 @@
1
+ # 002|9T Phase0 实现总结
2
+
3
+ ## 1. 当前已实现
4
+ - 最小可用 9T CLI 客户端,可在终端直接运行并调用工具链完成文件操作
5
+ - 9T 工具集合:read / edit / create / delete / ls / grep / glob / execute / open
6
+ - ChatGLM 接入:使用 glm-5 模型与 `https://open.bigmodel.cn/api/anthropic` 作为请求基址
7
+ - 运行方式:默认启动交互模式;支持单次命令模式与 `--test` 测试模式
8
+ - 交互指令:`/tools`、`/?`、`/help`、`/about`、`/exit`
9
+ - 工作区隔离:所有路径限制在 Movevom/workspace 内
10
+
11
+ ## 2. 目录与入口
12
+ - Movevom/config/provider.json:保存 base_url、model、api_key
13
+ - Movevom/cli-9t/index.mjs:CLI 主入口
14
+ - Movevom/workspace:工具操作的工作区
15
+
16
+ ## 3. 核心实现方式
17
+ - 通过系统提示词约束模型仅输出 JSON 工具调用
18
+ - 客户端解析 JSON,映射到 9T 工具执行
19
+ - 工具执行结果以 `TOOL_RESULT` 回写到对话上下文,驱动下一步工具调用
20
+ - 路径统一解析为 workspace 相对路径,拒绝绝对路径与越界访问
21
+
22
+ ## 4. 交互与验证方式
23
+ - 单次命令:`9t "<任务文本>"`,模型完成后输出 final
24
+ - 交互对话:`9t` 默认进入交互模式,通过 9T 工具修改/创建文件
25
+ - 测试模式:`9t --test` 生成 `demo/test.md` 并写入“惠风和畅”
26
+ - 验证结果:在 Movevom/workspace 内生成目标文件即可视为成功
@@ -0,0 +1,147 @@
1
+ # 003|9T CLI 权限与多设备安装(方案草案)
2
+
3
+ ## 1. 文件夹权限(工作区与授权目录)
4
+ - 目标:既能严格限制在 workspace 内,又可在用户同意后扩展到 Documents/Downloads 等授权目录;始终可回退为仅 workspace。
5
+ - 默认策略(安全默认):
6
+ - 路径必须相对工作区,拒绝绝对路径与 `..` 越级
7
+ - 软链逃逸防护:对目标路径做 `realpath` 标准化后再校验是否落在允许集合
8
+ - 高风险工具(delete/execute/open)强制更严规则(见模式说明)
9
+ - 模式设计:
10
+ - strict:仅 workspace(默认),delete/execute/open 禁止或需显式启用
11
+ - supervised:workspace + 显式授权目录(allowlist),高风险工具 ask/二次确认
12
+ - open:workspace + 授权目录,按规则直接执行(不推荐)
13
+ - 授权目录(allowlist)
14
+ - macOS 常用:`~/Documents`、`~/Downloads`(通过 `~` 展开到绝对路径)
15
+ - Linux:`~/Documents`、`~/Downloads`
16
+ - Windows:`%USERPROFILE%\\Documents`、`%USERPROFILE%\\Downloads`
17
+ - 配置入口(不改现有实现,仅描述方案)
18
+ - 配置文件:优先按平台存放(若全局安装)
19
+ - macOS:`~/Library/Application Support/9T/config.json`
20
+ - Linux:`$XDG_CONFIG_HOME/9t/config.json` 或 `~/.config/9t/config.json`
21
+ - Windows:`%AppData%\\9T\\config.json`
22
+ - 环境变量(覆盖配置文件):
23
+ - `9T_ALLOWED_DIRS`:逗号分隔的绝对路径列表
24
+ - `9T_MODE`:`strict|supervised|open`
25
+ - CLI 参数(优先级最高):
26
+ - `--mode <strict|supervised|open>`
27
+ - `--roots "<abs1,abs2,...>"`(授权目录列表)
28
+ - `--workspace <path>`(覆盖工作区根)
29
+ - 执行时校验(关键点)
30
+ - 统一将所有传入路径标准化后比较:
31
+ - 若 `realpath(target)` 不在 `[workspace] ∪ [allowlist]` 中,拒绝或进入 ask 流程(由模式决定)
32
+ - 对 delete/execute/open:
33
+ - strict:拒绝(或要求带 `--allow-risk delete|execute|open` 才可执行)
34
+ - supervised:询问二次确认(交互/标志)
35
+ - open:按规则直接执行
36
+
37
+ ## 2. 本地联调(不经发布的快速调试)
38
+ - 目标:避免“修改 → 发布 → 安装 → 调试”,实现“修改 → 本地调试”
39
+ - 推荐方式 A:直接运行入口文件
40
+ - - 进入仓库后执行:`node Movevom/cli-9t/index.mjs`
41
+ - 适合最轻量的本地验证,不依赖全局安装
42
+ - 推荐方式 B:全局软链接(npm link)
43
+ - 在 9T 项目根目录执行:`npm link`
44
+ - 在任意目录直接使用:`9t`
45
+ - 修改代码后无需重新安装,命令仍指向本地源码
46
+ - 取消软链接:`npm unlink -g 9t`(或 `9t-movevom`)
47
+ - 推荐方式 C:本地 PATH 指向
48
+ - 把 `Movevom/cli-9t` 加入 PATH 或创建本地别名
49
+ - 适合脚本化或团队内共享的本地开发流程
50
+
51
+ ## 3. 多设备安装与命令行分发(npm 路线)
52
+ - 包名与命令名
53
+ - - 包名:`@movevom/9t`
54
+ - 命令名:`9t`
55
+ - 目录与打包要点
56
+ - 使用 ESModule(`"type": "module"`),将 `cli-9t/index.mjs` 作为 `bin` 入口
57
+ - Node.js 要求:≥ 18(内置 fetch;统一运行时行为)
58
+ - 跨平台:通过 Node 实现文件/命令工具,避免平台特定依赖
59
+ - 全局安装与更新
60
+ - - 全局安装:`npm i -g @movevom/9t`
61
+ - 运行:`9t`(默认直接进入交互模式)
62
+ - 测试:`9t --test`(创建 `demo/test.md` 并写入“惠风和畅”)
63
+ - 更新:`npm update -g @movevom/9t`
64
+ - 多设备配置(与权限配置结合)
65
+ - Provider(LLM)配置默认路径:
66
+ - macOS:`~/Library/Application Support/9T/provider.json`
67
+ - Linux:`~/.config/9t/provider.json`
68
+ - Windows:`%AppData%\\9T\\provider.json`
69
+ - 环境变量覆盖:
70
+ - `9T_API_KEY`、`9T_BASE_URL`、`9T_MODEL`
71
+ - 设备迁移:复制上述配置文件与 `config.json`(含 `allowed_dirs`、`mode`、`workspace`)
72
+ - 命令行接口(建议补充)
73
+ - 运行模式:
74
+ - `9t` 默认交互模式
75
+ - `9t --interactive` 显式交互模式
76
+ - `9t --test` 生成 `demo/test.md` 测试任务
77
+ - `9t --key-status` 查看 API Key 是否已存储
78
+ - `9t "<任务文本>"` 单次执行
79
+ - 交互指令:`/tools`、`/?`、`/help`、`/about`、`/keystatus`、`/exit`
80
+ - 权限相关:
81
+ - `9t --mode strict|supervised|open`
82
+ - `9t --roots "/Users/me/Documents,/Users/me/Downloads"`
83
+ - `9t --workspace "/path/to/workspace"`
84
+ - `9t --allow-risk delete`(显式允许高风险工具,逗号分隔)
85
+ - 安全默认与提示
86
+ - 首次运行采用 strict + workspace-only
87
+ - 用户显式提供 `--roots`/`--mode` 后,命令行回显“授权目录与当前模式”以提示风险
88
+ - 对高风险工具在 supervised 模式下必须二次确认(交互式)或通过标志显式允许(非交互式)
89
+
90
+ ## 4. 权限配置细节
91
+ - 配置优先级:CLI 参数 > 环境变量 > 配置文件 > 默认值
92
+ - 配置样例(config.json)
93
+ - 结构:
94
+ - `mode`: `strict|supervised|open`
95
+ - `workspace`: 绝对路径
96
+ - `allowed_dirs`: 绝对路径数组
97
+ - `allow_risk`: `["delete","execute","open"]`
98
+ - 典型配置组合
99
+ - 仅 workspace:`mode=strict` + `allowed_dirs=[]`
100
+ - 扩展到 Documents/Downloads:`mode=supervised` + `allowed_dirs=[~/Documents, ~/Downloads]`
101
+ - 自动化批处理:`mode=open` + 明确 `allowed_dirs` + CI 内运行
102
+ - 交互确认
103
+ - supervised 模式在 delete/execute/open 时必须二次确认
104
+ - 非交互执行需显式 `--allow-risk`,否则拒绝
105
+ - 软链与越界
106
+ - 所有路径需 `realpath` 规范化后再匹配 allowlist
107
+ - 任何 `..`、绝对路径、跨盘符需被阻断或转入 ask 流程
108
+
109
+ ## 5. Key 存储与安全
110
+ - 行为
111
+ - 若 `9T_API_KEY` 已设置,优先使用
112
+ - 否则从系统安全存储读取
113
+ - 若仍缺失,提示 `apikey:` 且隐藏输入,回车后写入系统安全存储
114
+ - 查询状态:`9t --key-status` 或交互内 `/keystatus`
115
+ - 系统安全存储
116
+ - macOS:Keychain(security)
117
+ - Linux:libsecret(secret-tool)
118
+ - Windows:Credential Manager(CredRead/CredWrite)
119
+
120
+ ## 6. npm 打包发布细节
121
+ - package.json 关键字段
122
+ - 位置:`Movevom/package.json`
123
+ - `"name": "@movevom/9t"`
124
+ - `"type": "module"`
125
+ - `"bin": { "9t": "cli-9t/index.mjs" }`
126
+ - 发布流程(组织作用域)
127
+ - `npm login`
128
+ - `npm org ls movevom` 确认账号已加入组织
129
+ - 首次发布:`npm publish --access public`
130
+ - 版本更新:`npm version patch|minor|major` 后再发布
131
+ - 版本策略
132
+ - CLI 行为变更走 minor 或 major
133
+ - 配置格式变更必须增加迁移说明
134
+ - 跨平台注意
135
+ - 避免依赖平台命令;必要时封装为 Node 实现
136
+ - Windows 路径需兼容反斜杠与空格
137
+
138
+ ## 7. 使用场景与建议
139
+ - 仅操作项目目录:使用 strict + workspace-only(默认)
140
+ - 管理个人资料库(Documents/Downloads):使用 supervised + 指定 `--roots`,保留二次确认
141
+ - 自动化脚本/CI 场景:使用 open + 明确 `--roots`,但在受控环境下运行
142
+
143
+ ## 8. 后续落地清单(不在本次提交中修改代码)
144
+ - 读取并合并多源配置(CLI 参数 > 环境变量 > 配置文件 > 默认)
145
+ - 在路径解析函数中加入“授权目录集合校验”与“软链逃逸检测”
146
+ - 引入模式与高风险工具执行前置检查/确认
147
+ - 输出运行模式与授权目录摘要,便于用户核对
@@ -0,0 +1,61 @@
1
+ # 004|Gateway(网关)与 9T 的结合方案
2
+
3
+ ## 1. Gateway 是什么
4
+ - Gateway 是“统一入口 + 权限边界 + 会话中转”的服务层
5
+ - 作用:在用户、外部渠道与 9T CLI 之间做请求接入、鉴权、路由与审计
6
+ - 目标:把 9T 从“本地 CLI”升级为“可被多端安全调用的能力服务”
7
+
8
+ ## 2. 9T 为什么需要 Gateway
9
+ - 多入口接入:Web、Bot、Webhook、移动端都需要统一入口
10
+ - 安全边界:把密钥、权限、速率、审计放在 CLI 之外
11
+ - 任务编排:把“接入层”与“执行层”解耦,便于扩展
12
+
13
+ ## 3. Gateway 在 9T 的位置
14
+ ```
15
+ 用户/平台 → Gateway → 9T CLI → 工具执行 → 回写结果
16
+ ```
17
+ - Gateway 负责鉴权与路由
18
+ - 9T CLI 专注执行与工具调用
19
+ - 结果回传到 Gateway,再返回用户或外部系统
20
+
21
+ ## 4. 网关的核心功能清单
22
+ - 鉴权:API Key、Token、OAuth、签名校验
23
+ - 速率与成本控制:按用户/项目限流、限额
24
+ - 会话管理:会话 ID、上下文压缩、上下文清理
25
+ - 日志与审计:请求输入、输出摘要、时延、成本
26
+ - 安全策略:敏感命令拦截、黑白名单
27
+ - 路由:按任务类型选择模型或执行后端
28
+
29
+ ## 5. 9T 的 Gateway 方案(建议最小实现)
30
+ - HTTP Gateway(本地或内网服务)
31
+ - `/health`:健康检查
32
+ - `/pair`:首次配对,生成会话 token
33
+ - `/run`:提交任务,返回结果
34
+ - 鉴权策略
35
+ - 默认只允许 localhost
36
+ - token 必须在请求头中携带
37
+ - 任务执行策略
38
+ - Gateway 只负责转发与审计,不直接执行工具
39
+ - 9T CLI 按当前 workspace 与权限配置执行
40
+
41
+ ## 6. 典型使用场景
42
+ - 本地开发:网页或桌面 UI 通过 Gateway 调用 9T
43
+ - 团队协作:统一入口 + 权限边界,减少 CLI 直连风险
44
+ - 多端接入:Webhook / Bot / Mobile 走同一通道
45
+
46
+ ## 7. 关键安全设计
47
+ - 默认不对公网开放端口
48
+ - 需要显式配对(pair)才能发起任务
49
+ - Gateway 只接受允许的源地址或网关层白名单
50
+ - 所有操作记录日志并可回放
51
+
52
+ ## 8. 与 9T 当前实现的关系
53
+ - 当前 9T 是纯 CLI,本地执行
54
+ - Gateway 作为 Phase1 的可选扩展层
55
+ - 先完成 CLI → 再增加 Gateway → 最后支持 Web/多端
56
+
57
+ ## 9. 后续落地清单
58
+ - 设计最小 Gateway API 协议
59
+ - 确定 token 与配对方式
60
+ - 适配 CLI 的“任务执行入口”
61
+ - 增加审计日志与调用追踪
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@movevom/9t",
3
+ "version": "0.1.0",
4
+ "description": "9T CLI tools agent",
5
+ "type": "module",
6
+ "bin": {
7
+ "9t": "cli-9t/index.mjs"
8
+ },
9
+ "files": [
10
+ "cli-9t",
11
+ "config",
12
+ "docs"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "license": "MIT"
21
+ }