@miruzero/zero-agent 0.2.0 → 0.3.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 +26 -4
- package/package.json +1 -1
- package/src/agent.js +34 -0
- package/src/autostart.js +81 -0
- package/src/cli.js +48 -5
- package/src/local-tools.js +145 -0
package/README.md
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
|
-
# Zero Agent
|
|
1
|
+
# Zero Agent
|
|
2
2
|
|
|
3
3
|
Local worker agent for Zero MCP multi-device execution.
|
|
4
4
|
|
|
5
|
+
## One-command start
|
|
6
|
+
|
|
7
|
+
For a normal user, start with npm/npx and let the agent create local files automatically:
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
npx @miruzero/zero-agent@latest start --name TON --code ZERO-XXXX
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or inside this package:
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
npm start
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
On first start, the agent creates:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
%USERPROFILE%\.zero-agent\device.json
|
|
23
|
+
%USERPROFILE%\.zero-agent\start-zero-agent.cmd
|
|
24
|
+
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\MiruZeroAgent.vbs
|
|
25
|
+
%USERPROFILE%\.zero-agent\logs\zero-agent.log
|
|
26
|
+
```
|
|
27
|
+
|
|
5
28
|
## Commands
|
|
6
29
|
|
|
7
30
|
```powershell
|
|
8
|
-
zero-agent
|
|
9
|
-
zero-agent pair --server https://zero.miru.work --code ZERO-XXXX
|
|
31
|
+
zero-agent setup --server https://zero.miru.work --name TON --code ZERO-XXXX
|
|
10
32
|
zero-agent start --once
|
|
11
33
|
zero-agent start
|
|
12
34
|
zero-agent status
|
|
@@ -33,4 +55,4 @@ Device tokens are local secrets and must not be printed or committed.
|
|
|
33
55
|
```powershell
|
|
34
56
|
npm test
|
|
35
57
|
npm pack --dry-run
|
|
36
|
-
```
|
|
58
|
+
```
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -4,10 +4,15 @@ const os = require('node:os');
|
|
|
4
4
|
const config = require('./config');
|
|
5
5
|
const caps = require('./capabilities');
|
|
6
6
|
const { requestJson } = require('./http');
|
|
7
|
+
const localTools = require('./local-tools');
|
|
7
8
|
|
|
8
9
|
const nowIso = () => new Date().toISOString();
|
|
9
10
|
|
|
10
11
|
const serverBase = (server) => String(server || 'https://zero.miru.work').replace(/\/$/, '');
|
|
12
|
+
const trace = (env, event, payload = {}) => {
|
|
13
|
+
if (env.ZERO_TRACE !== '1') return;
|
|
14
|
+
console.log(JSON.stringify({ at: nowIso(), event, ...payload }));
|
|
15
|
+
};
|
|
11
16
|
|
|
12
17
|
const initDevice = ({ name, server }, env = process.env) => {
|
|
13
18
|
const host = os.hostname();
|
|
@@ -95,6 +100,34 @@ const syncCapabilitiesOnce = async (env = process.env) => {
|
|
|
95
100
|
return { ok: result.ok, status: result.status, body: result.json };
|
|
96
101
|
};
|
|
97
102
|
|
|
103
|
+
|
|
104
|
+
const pollTaskOnce = async (env = process.env) => {
|
|
105
|
+
const device = getPairedDevice(env);
|
|
106
|
+
if (!device) return { ok: false, code: 'NOT_PAIRED' };
|
|
107
|
+
const nextUrl = serverBase(device.server) + '/devices/' + encodeURIComponent(device.device_id) + '/tasks/next';
|
|
108
|
+
const next = await requestJson(nextUrl, { method: 'GET', token: device.device_token });
|
|
109
|
+
if (!next.ok) return { ok: false, status: next.status, body: next.json };
|
|
110
|
+
const task = next.json?.task || null;
|
|
111
|
+
if (!task) return { ok: true, task: null };
|
|
112
|
+
const started = Date.now();
|
|
113
|
+
trace(env, 'agent.task.received', { task_id: task.task_id, tool: task.tool, args: task.arguments || {} });
|
|
114
|
+
const resultUrl = serverBase(device.server) + '/devices/' + encodeURIComponent(device.device_id)
|
|
115
|
+
+ '/tasks/' + encodeURIComponent(task.task_id) + '/result';
|
|
116
|
+
try {
|
|
117
|
+
const result = localTools.execute(task.tool, task.arguments || {}, env);
|
|
118
|
+
trace(env, 'agent.task.done', { task_id: task.task_id, ok: true, duration_ms: Date.now() - started });
|
|
119
|
+
const posted = await requestJson(resultUrl, { method: 'POST', token: device.device_token, body: { ok: true, result } });
|
|
120
|
+
const finalTask = posted.json?.task || task;
|
|
121
|
+
return { ok: posted.ok, task: finalTask, result, posted: { status: posted.status, body: posted.json } };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const payload = { ok: false, error: { code: error.code || 'AGENT_EXEC_ERROR', message: error.message || String(error) } };
|
|
124
|
+
trace(env, 'agent.task.done', { task_id: task.task_id, ok: false, error: payload.error, duration_ms: Date.now() - started });
|
|
125
|
+
const posted = await requestJson(resultUrl, { method: 'POST', token: device.device_token, body: payload });
|
|
126
|
+
const finalTask = posted.json?.task || task;
|
|
127
|
+
return { ok: false, task: finalTask, error: payload.error, posted: { status: posted.status, body: posted.json } };
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
98
131
|
const listDevices = async (env = process.env) => {
|
|
99
132
|
const device = config.loadDevice(env);
|
|
100
133
|
const result = await requestJson(`${serverBase(device?.server)}/devices`);
|
|
@@ -106,6 +139,7 @@ module.exports = {
|
|
|
106
139
|
heartbeatPayload,
|
|
107
140
|
heartbeatOnce,
|
|
108
141
|
syncCapabilitiesOnce,
|
|
142
|
+
pollTaskOnce,
|
|
109
143
|
listDevices,
|
|
110
144
|
getPairedDevice
|
|
111
145
|
};
|
package/src/autostart.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_INTERVAL = 30000;
|
|
9
|
+
|
|
10
|
+
const q = (value) => '"' + String(value).replace(/"/g, '""') + '"';
|
|
11
|
+
const winPath = (value) => String(value).replace(/\//g, '\\');
|
|
12
|
+
|
|
13
|
+
const defaultRoots = (env = process.env) => {
|
|
14
|
+
if (env.ZERO_AGENT_ROOTS) return env.ZERO_AGENT_ROOTS;
|
|
15
|
+
const home = env.USERPROFILE || os.homedir();
|
|
16
|
+
return [
|
|
17
|
+
path.join(home, 'Desktop', 'zero'),
|
|
18
|
+
path.join(home, 'Downloads')
|
|
19
|
+
].join(path.delimiter);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const startupDir = (env = process.env) => {
|
|
23
|
+
const appData = env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
24
|
+
return path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const paths = (env = process.env) => {
|
|
28
|
+
const home = config.agentHome(env);
|
|
29
|
+
return {
|
|
30
|
+
home,
|
|
31
|
+
launcherFile: path.join(home, 'start-zero-agent.cmd'),
|
|
32
|
+
startupFile: path.join(startupDir(env), 'MiruZeroAgent.vbs'),
|
|
33
|
+
logFile: path.join(config.logDir(env), 'zero-agent.log')
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const launcherContent = ({ interval = DEFAULT_INTERVAL, roots, logFile }, env = process.env) => {
|
|
38
|
+
const home = config.agentHome(env);
|
|
39
|
+
return [
|
|
40
|
+
'@echo off',
|
|
41
|
+
'setlocal',
|
|
42
|
+
'set "ZERO_TRACE=1"',
|
|
43
|
+
'set "ZERO_AGENT_HOME=' + winPath(home) + '"',
|
|
44
|
+
'set "ZERO_AGENT_ROOTS=' + winPath(roots) + '"',
|
|
45
|
+
'set "ZERO_AGENT_INTERVAL=' + Number(interval || DEFAULT_INTERVAL) + '"',
|
|
46
|
+
'rem interval=' + Number(interval || DEFAULT_INTERVAL) + 'ms',
|
|
47
|
+
'if not exist ' + q(winPath(path.dirname(logFile))) + ' mkdir ' + q(winPath(path.dirname(logFile))),
|
|
48
|
+
'echo [%date% %time%] starting zero-agent >> ' + q(winPath(logFile)),
|
|
49
|
+
'zero-agent start --interval %ZERO_AGENT_INTERVAL% >> ' + q(winPath(logFile)) + ' 2>&1',
|
|
50
|
+
''
|
|
51
|
+
].join('\r\n');
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const vbsContent = (launcherFile) => [
|
|
55
|
+
'Set WshShell = CreateObject("WScript.Shell")',
|
|
56
|
+
'WshShell.Run Chr(34) & "' + winPath(launcherFile).replace(/"/g, '""') + '" & Chr(34), 0, False',
|
|
57
|
+
''
|
|
58
|
+
].join('\r\n');
|
|
59
|
+
|
|
60
|
+
const prepare = (options = {}, env = process.env) => {
|
|
61
|
+
config.ensureDirs(env);
|
|
62
|
+
const p = paths(env);
|
|
63
|
+
const interval = Number(options.interval || env.ZERO_AGENT_INTERVAL || DEFAULT_INTERVAL);
|
|
64
|
+
const roots = options.roots || defaultRoots(env);
|
|
65
|
+
fs.mkdirSync(path.dirname(p.launcherFile), { recursive: true });
|
|
66
|
+
fs.mkdirSync(path.dirname(p.startupFile), { recursive: true });
|
|
67
|
+
fs.mkdirSync(path.dirname(p.logFile), { recursive: true });
|
|
68
|
+
fs.writeFileSync(p.launcherFile, launcherContent({ interval, roots, logFile: p.logFile }, env), 'utf8');
|
|
69
|
+
fs.writeFileSync(p.startupFile, vbsContent(p.launcherFile), 'utf8');
|
|
70
|
+
return { ok: true, interval, roots, ...p };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const autostart = {
|
|
74
|
+
DEFAULT_INTERVAL,
|
|
75
|
+
defaultRoots,
|
|
76
|
+
paths,
|
|
77
|
+
prepare
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
Object.assign(exports, autostart);
|
|
81
|
+
|
package/src/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ const childProcess = require('node:child_process');
|
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const pkg = require('../package.json');
|
|
6
6
|
const config = require('./config');
|
|
7
|
+
const autostart = require('./autostart');
|
|
7
8
|
const caps = require('./capabilities');
|
|
8
9
|
const agent = require('./agent');
|
|
9
10
|
const { parseArgs } = require('./args');
|
|
@@ -14,7 +15,7 @@ Usage:
|
|
|
14
15
|
zero-agent setup [--server <url>] [--name <device>] [--code <pairing-code>] [--no-global]
|
|
15
16
|
zero-agent init --server <url> [--name <device>]
|
|
16
17
|
zero-agent pair --server <url> --code <pairing-code> [--name <device>]
|
|
17
|
-
zero-agent start [--once] [--interval <ms>]
|
|
18
|
+
zero-agent start [--once] [--interval <ms>] [--code <pairing-code>] [--name <device>]
|
|
18
19
|
zero-agent status
|
|
19
20
|
zero-agent devices
|
|
20
21
|
zero-agent doctor
|
|
@@ -27,6 +28,42 @@ const printJson = (value) => console.log(JSON.stringify(value, null, 2));
|
|
|
27
28
|
const okMark = (ok) => ok ? 'OK' : 'FAIL';
|
|
28
29
|
const DEFAULT_SERVER = 'https://zero.miru.work';
|
|
29
30
|
|
|
31
|
+
const autostartInterval = (parsed) => Number(parsed.interval || autostart.DEFAULT_INTERVAL);
|
|
32
|
+
|
|
33
|
+
const printAutostartInfo = (prepared) => {
|
|
34
|
+
console.log('Autostart file');
|
|
35
|
+
console.log(prepared.startupFile);
|
|
36
|
+
console.log('ตัวนี้จะรันตอน user logon และเปิด:');
|
|
37
|
+
console.log(prepared.launcherFile);
|
|
38
|
+
console.log('Launcher');
|
|
39
|
+
console.log(prepared.launcherFile);
|
|
40
|
+
console.log('ตั้งค่า:');
|
|
41
|
+
console.log('ZERO_TRACE=1');
|
|
42
|
+
console.log('ZERO_AGENT_ROOTS=' + prepared.roots);
|
|
43
|
+
console.log('interval=' + prepared.interval + 'ms');
|
|
44
|
+
console.log('log=' + prepared.logFile);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ensureLocalBootstrap = async (parsed, env) => {
|
|
48
|
+
let device = config.loadDevice(env);
|
|
49
|
+
if (!device) {
|
|
50
|
+
const init = agent.initDevice({ name: parsed.name, server: parsed.server || DEFAULT_SERVER }, env);
|
|
51
|
+
console.log('autoInit=OK file=' + init.file);
|
|
52
|
+
device = config.loadDevice(env);
|
|
53
|
+
}
|
|
54
|
+
const prepared = autostart.prepare({ interval: autostartInterval(parsed) }, env);
|
|
55
|
+
console.log('autoSetup=OK launcher=' + prepared.launcherFile);
|
|
56
|
+
if (parsed.code && (!device || !device.device_id)) {
|
|
57
|
+
const paired = await agent.pairDevice({
|
|
58
|
+
name: parsed.name || device?.name,
|
|
59
|
+
server: parsed.server || device?.server || DEFAULT_SERVER,
|
|
60
|
+
code: parsed.code
|
|
61
|
+
}, env);
|
|
62
|
+
console.log('autoPair=OK file=' + paired.file);
|
|
63
|
+
}
|
|
64
|
+
return prepared;
|
|
65
|
+
};
|
|
66
|
+
|
|
30
67
|
const runGlobalInstall = (parsed, env) => {
|
|
31
68
|
if (parsed['no-global'] || env.ZERO_AGENT_SKIP_GLOBAL_INSTALL === '1') {
|
|
32
69
|
console.log('globalInstall=SKIPPED');
|
|
@@ -53,6 +90,8 @@ const runSetup = async (parsed, env) => {
|
|
|
53
90
|
runGlobalInstall(parsed, env);
|
|
54
91
|
const init = agent.initDevice({ name, server }, env);
|
|
55
92
|
console.log('init=OK file=' + init.file);
|
|
93
|
+
const prepared = autostart.prepare({ interval: autostartInterval(parsed) }, env);
|
|
94
|
+
printAutostartInfo(prepared);
|
|
56
95
|
if (!parsed.code) {
|
|
57
96
|
console.log('pairing=NEEDS_CODE');
|
|
58
97
|
console.log('Next: zero-agent pair --server ' + server + ' --code ZERO-XXXXXX --name ' + init.device.name);
|
|
@@ -93,7 +132,8 @@ const runStartOnce = async (env) => {
|
|
|
93
132
|
const heartbeat = await agent.heartbeatOnce(env);
|
|
94
133
|
if (!heartbeat.ok && heartbeat.code === 'NOT_PAIRED') return heartbeat;
|
|
95
134
|
const capability = await agent.syncCapabilitiesOnce(env);
|
|
96
|
-
|
|
135
|
+
const task = await agent.pollTaskOnce(env);
|
|
136
|
+
return { heartbeat, capability, task };
|
|
97
137
|
};
|
|
98
138
|
|
|
99
139
|
const printStartBanner = (env) => {
|
|
@@ -113,6 +153,7 @@ const printLoopResult = (result) => {
|
|
|
113
153
|
const hb = result.heartbeat || {};
|
|
114
154
|
const cap = result.capability || {};
|
|
115
155
|
const device = cap.body?.device || hb.body?.device || null;
|
|
156
|
+
const taskState = result.task?.task ? (result.task.task.task_id + ':' + (result.task.task.status || 'done')) : 'none';
|
|
116
157
|
console.log(JSON.stringify({
|
|
117
158
|
at: new Date().toISOString(),
|
|
118
159
|
heartbeat: okMark(Boolean(hb.ok)),
|
|
@@ -120,8 +161,9 @@ const printLoopResult = (result) => {
|
|
|
120
161
|
status: device?.status || null,
|
|
121
162
|
name: device?.name || null,
|
|
122
163
|
host: device?.host || null,
|
|
123
|
-
providers: device?.provider_count
|
|
124
|
-
last_seen: device?.last_seen || null
|
|
164
|
+
providers: device?.provider_count ?? null,
|
|
165
|
+
last_seen: device?.last_seen || null,
|
|
166
|
+
task: taskState
|
|
125
167
|
}));
|
|
126
168
|
};
|
|
127
169
|
|
|
@@ -173,11 +215,12 @@ async function run(argv = [], env = process.env) {
|
|
|
173
215
|
return 0;
|
|
174
216
|
}
|
|
175
217
|
if (command === 'start') {
|
|
218
|
+
await ensureLocalBootstrap(parsed, env);
|
|
176
219
|
if (parsed.once) {
|
|
177
220
|
printJson(await runStartOnce(env));
|
|
178
221
|
return 0;
|
|
179
222
|
}
|
|
180
|
-
const interval = Number(parsed.interval ||
|
|
223
|
+
const interval = Number(parsed.interval || autostart.DEFAULT_INTERVAL);
|
|
181
224
|
printStartBanner(env);
|
|
182
225
|
console.log('⏱️ Heartbeat interval: ' + interval + 'ms');
|
|
183
226
|
console.log('🟢 Device ready; press Ctrl+C to stop');
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: false });
|
|
8
|
+
const err = (code, message) => Object.assign(new Error(message), { code });
|
|
9
|
+
const normalize = (value) => path.resolve(String(value)).toLowerCase();
|
|
10
|
+
|
|
11
|
+
const rootsFor = (env = process.env) => {
|
|
12
|
+
const configured = String(env.ZERO_AGENT_ROOTS || '')
|
|
13
|
+
.split(';')
|
|
14
|
+
.map((item) => item.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
const roots = configured.length ? configured : [os.homedir(), process.cwd()];
|
|
17
|
+
return roots.map((item) => path.resolve(item));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const resolveAllowed = (inputPath, env = process.env) => {
|
|
21
|
+
if (!inputPath || typeof inputPath !== 'string') throw err('INVALID_PATH', 'path is required');
|
|
22
|
+
const roots = rootsFor(env);
|
|
23
|
+
const target = path.resolve(inputPath);
|
|
24
|
+
const targetNorm = normalize(target);
|
|
25
|
+
const ok = roots.some((root) => {
|
|
26
|
+
const rootNorm = normalize(root);
|
|
27
|
+
return targetNorm === rootNorm || targetNorm.startsWith(rootNorm + path.sep.toLowerCase());
|
|
28
|
+
});
|
|
29
|
+
if (!ok) throw err('PATH_NOT_ALLOWED', 'path is outside agent allowed roots');
|
|
30
|
+
return target;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const readUtf8 = (file) => {
|
|
34
|
+
const data = fs.readFileSync(file);
|
|
35
|
+
if (data.includes(0)) throw err('BINARY_FILE', 'binary file is not supported');
|
|
36
|
+
return textDecoder.decode(data);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const entry = (fullPath, root) => {
|
|
40
|
+
const stat = fs.statSync(fullPath);
|
|
41
|
+
return {
|
|
42
|
+
path: fullPath,
|
|
43
|
+
relativePath: path.relative(root, fullPath).replace(/\\/g, '/'),
|
|
44
|
+
type: stat.isDirectory() ? 'directory' : 'file',
|
|
45
|
+
size: stat.size
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const walk = (root, options = {}) => {
|
|
50
|
+
const depth = options.depth ?? Infinity;
|
|
51
|
+
const filesOnly = options.filesOnly === true;
|
|
52
|
+
const out = [];
|
|
53
|
+
const visit = (dir, level) => {
|
|
54
|
+
if (level > depth) return;
|
|
55
|
+
for (const name of fs.readdirSync(dir)) {
|
|
56
|
+
const full = path.join(dir, name);
|
|
57
|
+
const item = entry(full, root);
|
|
58
|
+
if (!filesOnly || item.type === 'file') out.push(item);
|
|
59
|
+
if (item.type === 'directory') visit(full, level + 1);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
visit(root, 1);
|
|
63
|
+
return out;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const wildcard = (pattern) => new RegExp('^' + String(pattern)
|
|
67
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
68
|
+
.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
|
|
69
|
+
|
|
70
|
+
const numbered = (lines, startIndex) => lines
|
|
71
|
+
.map((line, index) => String(startIndex + index + 1).padStart(4, ' ') + '\t' + line)
|
|
72
|
+
.join('\n');
|
|
73
|
+
|
|
74
|
+
const listDirectory = (args, env) => {
|
|
75
|
+
const root = resolveAllowed(args.path, env);
|
|
76
|
+
const depth = Math.max(1, Math.min(args.depth || 2, 5));
|
|
77
|
+
return { ok: true, path: root, depth, entries: walk(root, { depth }) };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const getFileInfo = (args, env) => {
|
|
81
|
+
const target = resolveAllowed(args.path, env);
|
|
82
|
+
const stat = fs.statSync(target);
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
path: target,
|
|
86
|
+
type: stat.isDirectory() ? 'directory' : 'file',
|
|
87
|
+
size: stat.size,
|
|
88
|
+
createdAt: stat.birthtime.toISOString(),
|
|
89
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
90
|
+
lineCount: stat.isFile() ? readUtf8(target).split(/\r?\n/).length : null
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const readFile = (args, env) => {
|
|
95
|
+
const file = resolveAllowed(args.path, env);
|
|
96
|
+
const lines = readUtf8(file).split(/\r?\n/);
|
|
97
|
+
const offset = Number.isInteger(args.offset) ? args.offset : 0;
|
|
98
|
+
const length = Number.isInteger(args.length) ? args.length : 200;
|
|
99
|
+
const start = offset < 0 ? Math.max(lines.length + offset, 0) : Math.max(offset, 0);
|
|
100
|
+
const slice = lines.slice(start, start + length);
|
|
101
|
+
return { ok: true, path: file, totalLines: lines.length, offset: start, length: slice.length, content: numbered(slice, start) };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const globFiles = (args, env) => {
|
|
105
|
+
const root = resolveAllowed(args.path, env);
|
|
106
|
+
const rx = wildcard(args.pattern || '*');
|
|
107
|
+
const max = Math.max(1, Math.min(args.maxResults || 200, 1000));
|
|
108
|
+
const files = walk(root, { filesOnly: true })
|
|
109
|
+
.filter((item) => rx.test(path.basename(item.path)) || rx.test(item.relativePath))
|
|
110
|
+
.slice(0, max);
|
|
111
|
+
return { ok: true, path: root, pattern: args.pattern, files };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const grepFiles = (args, env) => {
|
|
115
|
+
const root = resolveAllowed(args.path, env);
|
|
116
|
+
const max = Math.max(1, Math.min(args.maxResults || 200, 1000));
|
|
117
|
+
const rx = args.literal ? null : new RegExp(String(args.pattern), 'i');
|
|
118
|
+
const fileRx = args.filePattern ? wildcard(args.filePattern) : null;
|
|
119
|
+
const matches = [];
|
|
120
|
+
for (const file of walk(root, { filesOnly: true })) {
|
|
121
|
+
if (fileRx && !fileRx.test(path.basename(file.path)) && !fileRx.test(file.relativePath)) continue;
|
|
122
|
+
let text;
|
|
123
|
+
try { text = readUtf8(file.path); } catch { continue; }
|
|
124
|
+
const lines = text.split(/\r?\n/);
|
|
125
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
126
|
+
const line = lines[index];
|
|
127
|
+
const hit = args.literal ? line.includes(String(args.pattern)) : rx.test(line);
|
|
128
|
+
if (!hit) continue;
|
|
129
|
+
matches.push({ path: file.path, relativePath: file.relativePath, line: index + 1, text: line });
|
|
130
|
+
if (matches.length >= max) return { ok: true, path: root, pattern: args.pattern, matches };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, path: root, pattern: args.pattern, matches };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handlers = { listDirectory, getFileInfo, readFile, globFiles, grepFiles };
|
|
137
|
+
|
|
138
|
+
exports.execute = (tool, args = {}, env = process.env) => {
|
|
139
|
+
const cleanTool = String(tool || '').replace(/^command\./, '');
|
|
140
|
+
const handler = handlers[cleanTool];
|
|
141
|
+
if (!handler) throw err('UNKNOWN_AGENT_TOOL', 'unknown or unsafe agent tool: ' + tool);
|
|
142
|
+
return handler(args, env);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
exports._private = { rootsFor, resolveAllowed, wildcard };
|