scalix 0.0.1 → 1.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.
- package/.env.example +29 -0
- package/CHANGELOG.md +122 -0
- package/LICENSE +100 -0
- package/README.md +300 -2
- package/bin/scalix.js +19 -0
- package/dist/agent-definitions.js +100 -0
- package/dist/agent-registry.js +226 -0
- package/dist/agent-replay.js +166 -0
- package/dist/agent.js +1130 -0
- package/dist/api-client.js +77 -0
- package/dist/auto-verify.js +136 -0
- package/dist/change-tracker.js +53 -0
- package/dist/cli.js +2454 -0
- package/dist/commands.js +71 -0
- package/dist/config.js +16 -0
- package/dist/constants.js +358 -0
- package/dist/context-builder.js +158 -0
- package/dist/context-manager.js +148 -0
- package/dist/context-retriever.js +257 -0
- package/dist/cost-tracker.js +85 -0
- package/dist/diff.js +140 -0
- package/dist/git-context.js +183 -0
- package/dist/git-undo.js +67 -0
- package/dist/grounding-checker.js +154 -0
- package/dist/headless.js +743 -0
- package/dist/hooks.js +100 -0
- package/dist/injection-detector.js +86 -0
- package/dist/ipc-server.js +232 -0
- package/dist/logger.js +45 -0
- package/dist/markdown.js +41 -0
- package/dist/mcp.js +49 -0
- package/dist/model-router.js +68 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +376 -0
- package/dist/project-memory.js +165 -0
- package/dist/safe-math.js +194 -0
- package/dist/sandbox.js +403 -0
- package/dist/scoped-rules.js +82 -0
- package/dist/sessions.js +106 -0
- package/dist/settings.js +260 -0
- package/dist/sub-agent.js +154 -0
- package/dist/task-board.js +105 -0
- package/dist/token-counter.js +47 -0
- package/dist/tool-helper.js +97 -0
- package/dist/tool-selector.js +77 -0
- package/dist/tools-advanced.js +487 -0
- package/dist/tools-browser.js +534 -0
- package/dist/tools-extra.js +390 -0
- package/dist/tools-vision.js +116 -0
- package/dist/tools-voice.js +147 -0
- package/dist/tools-web.js +346 -0
- package/dist/tools.js +380 -0
- package/dist/types.js +1 -0
- package/dist/update-checker.js +111 -0
- package/dist/worktree.js +172 -0
- package/docs/DISTRIBUTION.md +301 -0
- package/package.json +71 -6
package/dist/tools.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { tool } from './tool-helper.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { exec, spawn } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import fsSync from 'node:fs';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { safeEvaluate } from './safe-math.js';
|
|
9
|
+
import { sandboxPath, isBlockedCommand, detectInjection, sanitizeEnvForCommand, getShellInfo } from './sandbox.js';
|
|
10
|
+
import { DEFAULT_LIMITS } from './settings.js';
|
|
11
|
+
import { TOOL_DEFAULTS, BINARY_DETECT_BUFFER_SIZE } from './constants.js';
|
|
12
|
+
import { computeDiff, formatDiff } from './diff.js';
|
|
13
|
+
let _limits = DEFAULT_LIMITS;
|
|
14
|
+
export function setToolLimits(limits) { _limits = limits; }
|
|
15
|
+
const execAsync = promisify(exec);
|
|
16
|
+
let persistentCwd = process.cwd();
|
|
17
|
+
export function getPersistentCwd() { return persistentCwd; }
|
|
18
|
+
export function setPersistentCwd(dir) { persistentCwd = dir; }
|
|
19
|
+
export function resetPersistentCwd() { persistentCwd = process.cwd(); }
|
|
20
|
+
export const backgroundProcesses = new Map();
|
|
21
|
+
export const timeTool = tool({
|
|
22
|
+
name: 'get_current_time',
|
|
23
|
+
description: 'Get the current date and time',
|
|
24
|
+
inputSchema: z.object({
|
|
25
|
+
timezone: z.string().max(100).optional().describe('Timezone (e.g., "UTC", "America/New_York")')
|
|
26
|
+
}),
|
|
27
|
+
execute: async ({ timezone }) => {
|
|
28
|
+
const tz = timezone || 'UTC';
|
|
29
|
+
try {
|
|
30
|
+
return {
|
|
31
|
+
time: new Date().toLocaleString('en-US', { timeZone: tz }),
|
|
32
|
+
timezone: tz,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { error: `Invalid timezone: "${tz}". Use IANA names like "America/New_York" or "UTC".` };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
export const calculatorTool = tool({
|
|
41
|
+
name: 'calculate',
|
|
42
|
+
description: 'Perform mathematical calculations. Supports +, -, *, /, %, ^, parentheses, and functions (sqrt, abs, sin, cos, tan, log, floor, ceil, round, min, max, pow, pi, e)',
|
|
43
|
+
inputSchema: z.object({
|
|
44
|
+
expression: z.string().max(1000).describe('Math expression (e.g., "2 + 2", "sqrt(16)")')
|
|
45
|
+
}),
|
|
46
|
+
execute: async ({ expression }) => {
|
|
47
|
+
try {
|
|
48
|
+
const result = safeEvaluate(expression);
|
|
49
|
+
return { expression, result };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return { expression, error: err.message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
// Extract cd target from a command to persist working directory
|
|
57
|
+
function extractCdTarget(command) {
|
|
58
|
+
const match = command.match(/^\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))\s*(&&|;|$)/);
|
|
59
|
+
if (match)
|
|
60
|
+
return match[2] || match[3] || match[4] || null;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
export const runCommandTool = tool({
|
|
64
|
+
name: 'run_command',
|
|
65
|
+
description: 'Run a shell command using the system shell (bash/zsh on Unix, PowerShell/cmd on Windows). Working directory persists between calls. Supports background execution and configurable timeout.',
|
|
66
|
+
inputSchema: z.object({
|
|
67
|
+
command: z.string().max(10000).describe('The command to run'),
|
|
68
|
+
description: z.string().max(500).optional().describe('Short description of what the command does'),
|
|
69
|
+
timeout: z.number().max(TOOL_DEFAULTS.maxCommandTimeoutMs).optional().describe(`Timeout in ms (default ${TOOL_DEFAULTS.commandTimeoutMs}, max ${TOOL_DEFAULTS.maxCommandTimeoutMs})`),
|
|
70
|
+
cwd: z.string().max(500).optional().describe('Working directory override (relative to workspace root)'),
|
|
71
|
+
run_in_background: z.boolean().optional().describe('Run command in background and return PID immediately'),
|
|
72
|
+
}),
|
|
73
|
+
execute: async ({ command, timeout, cwd, run_in_background: runInBg }) => {
|
|
74
|
+
if (isBlockedCommand(command)) {
|
|
75
|
+
return { error: 'Command blocked: matches a destructive pattern', exitCode: -1 };
|
|
76
|
+
}
|
|
77
|
+
const injection = detectInjection(command);
|
|
78
|
+
if (injection) {
|
|
79
|
+
return { error: `Command blocked: ${injection}`, exitCode: -1, requiresReview: true };
|
|
80
|
+
}
|
|
81
|
+
// Resolve working directory: explicit cwd > persistent cwd > process cwd
|
|
82
|
+
let workingDir;
|
|
83
|
+
if (cwd) {
|
|
84
|
+
try {
|
|
85
|
+
workingDir = sandboxPath(cwd);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
return { error: `Invalid working directory: ${err.message}`, exitCode: -1 };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
workingDir = persistentCwd;
|
|
93
|
+
}
|
|
94
|
+
const shell = getShellInfo();
|
|
95
|
+
if (runInBg) {
|
|
96
|
+
const child = spawn(shell.path, [...shell.args, command], {
|
|
97
|
+
detached: true,
|
|
98
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
|
+
env: sanitizeEnvForCommand(),
|
|
100
|
+
cwd: workingDir,
|
|
101
|
+
});
|
|
102
|
+
if (!child.pid) {
|
|
103
|
+
return { error: 'Failed to spawn background process: no PID assigned', exitCode: -1 };
|
|
104
|
+
}
|
|
105
|
+
const pid = child.pid;
|
|
106
|
+
const entry = {
|
|
107
|
+
pid,
|
|
108
|
+
command,
|
|
109
|
+
stdout: '',
|
|
110
|
+
stderr: '',
|
|
111
|
+
exitCode: null,
|
|
112
|
+
startedAt: Date.now(),
|
|
113
|
+
lastRead: 0,
|
|
114
|
+
};
|
|
115
|
+
child.stdout?.on('data', (data) => {
|
|
116
|
+
const chunk = data.toString();
|
|
117
|
+
if (entry.stdout.length + chunk.length <= _limits.bgBufferMax) {
|
|
118
|
+
entry.stdout += chunk;
|
|
119
|
+
}
|
|
120
|
+
else if (entry.stdout.length < _limits.bgBufferMax) {
|
|
121
|
+
entry.stdout += chunk.slice(0, _limits.bgBufferMax - entry.stdout.length);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
child.stdout?.on('error', () => { });
|
|
125
|
+
child.stderr?.on('data', (data) => {
|
|
126
|
+
const chunk = data.toString();
|
|
127
|
+
if (entry.stderr.length + chunk.length <= _limits.bgBufferMax) {
|
|
128
|
+
entry.stderr += chunk;
|
|
129
|
+
}
|
|
130
|
+
else if (entry.stderr.length < _limits.bgBufferMax) {
|
|
131
|
+
entry.stderr += chunk.slice(0, _limits.bgBufferMax - entry.stderr.length);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
child.stderr?.on('error', () => { });
|
|
135
|
+
child.on('error', (err) => {
|
|
136
|
+
entry.exitCode = -1;
|
|
137
|
+
const errMsg = `\nProcess error: ${err.message}`;
|
|
138
|
+
if (entry.stderr.length + errMsg.length <= _limits.bgBufferMax) {
|
|
139
|
+
entry.stderr += errMsg;
|
|
140
|
+
}
|
|
141
|
+
else if (entry.stderr.length < _limits.bgBufferMax) {
|
|
142
|
+
entry.stderr += errMsg.slice(0, _limits.bgBufferMax - entry.stderr.length);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
child.on('close', (code) => {
|
|
146
|
+
entry.exitCode = code ?? 1;
|
|
147
|
+
setTimeout(() => { backgroundProcesses.delete(pid); }, _limits.bgCleanupMs).unref();
|
|
148
|
+
});
|
|
149
|
+
backgroundProcesses.set(pid, entry);
|
|
150
|
+
child.unref();
|
|
151
|
+
return { pid, message: `Command started in background (PID: ${pid}). Use the monitor tool to check output.` };
|
|
152
|
+
}
|
|
153
|
+
// For cd-only commands, update persistent cwd directly
|
|
154
|
+
const cdTarget = extractCdTarget(command);
|
|
155
|
+
if (cdTarget && command.trim().match(/^cd\s/)) {
|
|
156
|
+
try {
|
|
157
|
+
const resolved = path.resolve(workingDir, cdTarget);
|
|
158
|
+
await fs.stat(resolved);
|
|
159
|
+
persistentCwd = resolved;
|
|
160
|
+
return { stdout: '', stderr: '', exitCode: 0, cwd: persistentCwd };
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
return { error: `cd: ${cdTarget}: ${err.code === 'ENOENT' ? 'No such file or directory' : err.message}`, exitCode: 1 };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
let fullCommand = command;
|
|
167
|
+
const CWD_MARKER = '__SCALIX_CWD_PROBE__';
|
|
168
|
+
if (cdTarget && !command.trim().match(/^cd\s+\S+\s*$/)) {
|
|
169
|
+
const cwdProbe = shell.type === 'cmd'
|
|
170
|
+
? `${command} && echo ${CWD_MARKER} && cd`
|
|
171
|
+
: shell.type === 'pwsh'
|
|
172
|
+
? `${command}; Write-Host '${CWD_MARKER}'; Get-Location | Select-Object -ExpandProperty Path`
|
|
173
|
+
: `${command} && echo '${CWD_MARKER}' && pwd`;
|
|
174
|
+
fullCommand = cwdProbe;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const { stdout, stderr } = await execAsync(fullCommand, {
|
|
178
|
+
timeout: timeout ?? _limits.commandTimeoutMs,
|
|
179
|
+
maxBuffer: TOOL_DEFAULTS.maxExecBufferDefault,
|
|
180
|
+
env: sanitizeEnvForCommand(),
|
|
181
|
+
cwd: workingDir,
|
|
182
|
+
shell: shell.path,
|
|
183
|
+
});
|
|
184
|
+
if (cdTarget && fullCommand !== command) {
|
|
185
|
+
const markerIdx = stdout.indexOf(CWD_MARKER);
|
|
186
|
+
if (markerIdx !== -1) {
|
|
187
|
+
const beforeMarker = stdout.slice(0, markerIdx).trimEnd();
|
|
188
|
+
const afterMarker = stdout.slice(markerIdx + CWD_MARKER.length).trim();
|
|
189
|
+
const newCwd = afterMarker.split('\n')[0]?.trim();
|
|
190
|
+
if (newCwd && path.isAbsolute(newCwd)) {
|
|
191
|
+
persistentCwd = newCwd;
|
|
192
|
+
return { stdout: beforeMarker, stderr, exitCode: 0, cwd: persistentCwd };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { stdout, stderr, exitCode: 0, cwd: workingDir };
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
const result = { stdout: error.stdout, stderr: error.stderr, exitCode: error.code || 1, error: error.message };
|
|
200
|
+
result.autoFixHint = 'The command failed. Analyze the error output above and suggest how to fix it. If it is a build/test/lint error, read the relevant file and fix the issue.';
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
export const readFileTool = tool({
|
|
206
|
+
name: 'read_file',
|
|
207
|
+
description: 'Read a local file with line numbers (lineN<tab>content). Returns the full file contents. Use read_file_lines if you only need a specific range.',
|
|
208
|
+
inputSchema: z.object({
|
|
209
|
+
filePath: z.string().max(500).describe('Relative path to the file'),
|
|
210
|
+
maxLines: z.number().min(1).optional().describe('Optional: limit output to first N lines'),
|
|
211
|
+
}),
|
|
212
|
+
execute: async ({ filePath, maxLines }) => {
|
|
213
|
+
try {
|
|
214
|
+
const safePath = sandboxPath(filePath);
|
|
215
|
+
const stat = await fs.stat(safePath);
|
|
216
|
+
if (stat.size > _limits.readFileMaxSize)
|
|
217
|
+
return { error: `File too large (${Math.round(stat.size / 1024)}KB, max ${Math.round(_limits.readFileMaxSize / 1024)}KB). Use read_file_lines to read specific sections.` };
|
|
218
|
+
const fd = await fs.open(safePath, 'r');
|
|
219
|
+
const header = Buffer.alloc(BINARY_DETECT_BUFFER_SIZE);
|
|
220
|
+
try {
|
|
221
|
+
await fd.read(header, 0, Math.min(BINARY_DETECT_BUFFER_SIZE, stat.size), 0);
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await fd.close();
|
|
225
|
+
}
|
|
226
|
+
if (header.slice(0, Math.min(BINARY_DETECT_BUFFER_SIZE, stat.size)).includes(0x00)) {
|
|
227
|
+
return { error: 'File appears to be binary. Use a specialized tool to inspect binary files.' };
|
|
228
|
+
}
|
|
229
|
+
const content = await fs.readFile(safePath, 'utf-8');
|
|
230
|
+
const lines = content.split('\n');
|
|
231
|
+
if (maxLines && lines.length > maxLines) {
|
|
232
|
+
const numbered = lines.slice(0, maxLines).map((line, i) => `${i + 1}\t${line}`).join('\n');
|
|
233
|
+
return {
|
|
234
|
+
content: numbered,
|
|
235
|
+
totalLines: lines.length,
|
|
236
|
+
truncatedAt: maxLines,
|
|
237
|
+
hint: `Showing first ${maxLines} of ${lines.length} lines.`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const numbered = lines.map((line, i) => `${i + 1}\t${line}`).join('\n');
|
|
241
|
+
return { content: numbered, totalLines: lines.length };
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
return { error: error.message };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
export const writeFileTool = tool({
|
|
249
|
+
name: 'write_file',
|
|
250
|
+
description: 'Write text content to a local file (sandboxed to working directory). Requires user confirmation.',
|
|
251
|
+
inputSchema: z.object({
|
|
252
|
+
filePath: z.string().max(500).describe('Relative path to the file'),
|
|
253
|
+
content: z.string().max(1024 * 1024).describe('The entire text content to write')
|
|
254
|
+
}),
|
|
255
|
+
execute: async ({ filePath, content }) => {
|
|
256
|
+
try {
|
|
257
|
+
const safePath = sandboxPath(filePath);
|
|
258
|
+
let existed = false;
|
|
259
|
+
let oldContent = '';
|
|
260
|
+
try {
|
|
261
|
+
oldContent = await fs.readFile(safePath, 'utf-8');
|
|
262
|
+
existed = true;
|
|
263
|
+
}
|
|
264
|
+
catch { }
|
|
265
|
+
await fs.mkdir(path.dirname(safePath), { recursive: true });
|
|
266
|
+
await fs.writeFile(safePath, content, 'utf-8');
|
|
267
|
+
let diff;
|
|
268
|
+
if (existed) {
|
|
269
|
+
const diffResult = computeDiff(oldContent, content);
|
|
270
|
+
diff = formatDiff(diffResult, filePath);
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
success: true,
|
|
274
|
+
message: `Wrote ${content.length} bytes to ${filePath}`,
|
|
275
|
+
...(existed ? { warning: 'File already existed and was overwritten' } : {}),
|
|
276
|
+
...(diff ? { diff } : {}),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
return { success: false, error: error.message };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
export const listFilesTool = tool({
|
|
285
|
+
name: 'list_files',
|
|
286
|
+
description: 'List files and directories in a given path (sandboxed to working directory). Supports recursive listing with glob patterns.',
|
|
287
|
+
inputSchema: z.object({
|
|
288
|
+
dirPath: z.string().max(500).optional().describe('Relative path (defaults to current directory)'),
|
|
289
|
+
recursive: z.boolean().optional().describe('Recursively list files (default: false)'),
|
|
290
|
+
pattern: z.string().max(200).optional().describe('Glob pattern filter when recursive (e.g., "**/*.ts")'),
|
|
291
|
+
maxResults: z.number().max(1000).optional().describe('Max results for recursive listing (default: 200)'),
|
|
292
|
+
}),
|
|
293
|
+
execute: async ({ dirPath, recursive, pattern, maxResults }) => {
|
|
294
|
+
try {
|
|
295
|
+
const safePath = sandboxPath(dirPath || '.');
|
|
296
|
+
if (recursive) {
|
|
297
|
+
const fg = (await import('fast-glob')).default;
|
|
298
|
+
const globPattern = pattern || '**/*';
|
|
299
|
+
const files = await fg(globPattern, {
|
|
300
|
+
cwd: safePath,
|
|
301
|
+
ignore: ['node_modules/**', '.git/**', 'dist/**', 'coverage/**'],
|
|
302
|
+
onlyFiles: true,
|
|
303
|
+
stats: true,
|
|
304
|
+
followSymbolicLinks: false,
|
|
305
|
+
});
|
|
306
|
+
const limit = maxResults ?? 200;
|
|
307
|
+
const sorted = files
|
|
308
|
+
.sort((a, b) => (b.stats?.mtimeMs ?? 0) - (a.stats?.mtimeMs ?? 0))
|
|
309
|
+
.slice(0, limit)
|
|
310
|
+
.map((f) => ({
|
|
311
|
+
name: f.path,
|
|
312
|
+
size: f.stats?.size ?? 0,
|
|
313
|
+
isDirectory: false,
|
|
314
|
+
}));
|
|
315
|
+
return { path: safePath, count: sorted.length, total: files.length, files: sorted };
|
|
316
|
+
}
|
|
317
|
+
const entries = await fs.readdir(safePath, { withFileTypes: true });
|
|
318
|
+
const results = await Promise.all(entries.map(async (e) => {
|
|
319
|
+
let size;
|
|
320
|
+
if (!e.isDirectory()) {
|
|
321
|
+
try {
|
|
322
|
+
const stat = await fs.stat(path.join(safePath, e.name));
|
|
323
|
+
size = stat.size;
|
|
324
|
+
}
|
|
325
|
+
catch { }
|
|
326
|
+
}
|
|
327
|
+
return { name: e.name, isDirectory: e.isDirectory(), ...(size !== undefined ? { size } : {}) };
|
|
328
|
+
}));
|
|
329
|
+
const sorted = results.sort((a, b) => {
|
|
330
|
+
if (a.isDirectory !== b.isDirectory)
|
|
331
|
+
return a.isDirectory ? -1 : 1;
|
|
332
|
+
return a.name.localeCompare(b.name);
|
|
333
|
+
});
|
|
334
|
+
return { path: safePath, count: sorted.length, files: sorted };
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
return { error: error.message };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
export const addMcpServerTool = tool({
|
|
342
|
+
name: 'add_mcp_server',
|
|
343
|
+
description: 'Add an HTTP MCP server persistently. Requires user confirmation.',
|
|
344
|
+
inputSchema: z.object({
|
|
345
|
+
label: z.string().min(1).max(100).describe('A logical name for the MCP server'),
|
|
346
|
+
url: z.string().url().describe('The HTTP SSE URL of the MCP server')
|
|
347
|
+
}),
|
|
348
|
+
execute: async ({ label, url }) => {
|
|
349
|
+
try {
|
|
350
|
+
const mcpConfigPath = path.resolve(process.cwd(), 'mcp-services.json');
|
|
351
|
+
let config = { mcpServers: [] };
|
|
352
|
+
try {
|
|
353
|
+
if (fsSync.existsSync(mcpConfigPath)) {
|
|
354
|
+
const parsed = JSON.parse(fsSync.readFileSync(mcpConfigPath, 'utf-8'));
|
|
355
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
356
|
+
config = parsed;
|
|
357
|
+
if (!Array.isArray(config.mcpServers))
|
|
358
|
+
config.mcpServers = [];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch { }
|
|
363
|
+
config.mcpServers.push({ serverLabel: label, serverUrl: url });
|
|
364
|
+
await fs.writeFile(mcpConfigPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
365
|
+
return { success: true, message: `Added MCP server ${label} at ${url}` };
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
return { success: false, error: e.message };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
export const defaultTools = [
|
|
373
|
+
timeTool,
|
|
374
|
+
calculatorTool,
|
|
375
|
+
runCommandTool,
|
|
376
|
+
readFileTool,
|
|
377
|
+
writeFileTool,
|
|
378
|
+
listFilesTool,
|
|
379
|
+
addMcpServerTool,
|
|
380
|
+
];
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { VERSION, UPDATE_DEFAULTS, USER_CONFIG_DIR } from './constants.js';
|
|
6
|
+
function semverGt(a, b) {
|
|
7
|
+
const pa = a.replace(/^v/, '').split('.').map(Number);
|
|
8
|
+
const pb = b.replace(/^v/, '').split('.').map(Number);
|
|
9
|
+
for (let i = 0; i < 3; i++) {
|
|
10
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0))
|
|
11
|
+
return true;
|
|
12
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0))
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function isValidSemver(v) {
|
|
18
|
+
return /^\d+\.\d+\.\d+/.test(v.replace(/^v/, ''));
|
|
19
|
+
}
|
|
20
|
+
function getCacheFile() {
|
|
21
|
+
return path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR, 'update-check.json');
|
|
22
|
+
}
|
|
23
|
+
function readCacheSync() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = readFileSync(getCacheFile(), 'utf-8');
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (typeof parsed.lastCheck === 'number' && typeof parsed.latestVersion === 'string') {
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch { }
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
async function writeCache(cache) {
|
|
35
|
+
const dir = path.dirname(getCacheFile());
|
|
36
|
+
await fs.mkdir(dir, { recursive: true });
|
|
37
|
+
await fs.writeFile(getCacheFile(), JSON.stringify(cache), { encoding: 'utf-8', mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
export async function checkForUpdate() {
|
|
40
|
+
if (process.env.NO_UPDATE_NOTIFIER)
|
|
41
|
+
return null;
|
|
42
|
+
const cached = readCacheSync();
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
if (cached && (now - cached.lastCheck) < UPDATE_DEFAULTS.checkIntervalMs) {
|
|
45
|
+
return {
|
|
46
|
+
current: VERSION,
|
|
47
|
+
latest: cached.latestVersion,
|
|
48
|
+
updateAvailable: semverGt(cached.latestVersion, VERSION),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(UPDATE_DEFAULTS.registryUrl, {
|
|
53
|
+
signal: AbortSignal.timeout(UPDATE_DEFAULTS.fetchTimeoutMs),
|
|
54
|
+
headers: { 'Accept': 'application/json' },
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok)
|
|
57
|
+
return null;
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
const latest = data.version;
|
|
60
|
+
if (!latest || !isValidSemver(latest))
|
|
61
|
+
return null;
|
|
62
|
+
await writeCache({ lastCheck: now, latestVersion: latest }).catch(() => { });
|
|
63
|
+
return {
|
|
64
|
+
current: VERSION,
|
|
65
|
+
latest,
|
|
66
|
+
updateAvailable: semverGt(latest, VERSION),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function getUpdateBanner() {
|
|
74
|
+
if (process.env.NO_UPDATE_NOTIFIER)
|
|
75
|
+
return null;
|
|
76
|
+
const cached = readCacheSync();
|
|
77
|
+
if (!cached)
|
|
78
|
+
return null;
|
|
79
|
+
if (!semverGt(cached.latestVersion, VERSION))
|
|
80
|
+
return null;
|
|
81
|
+
const current = VERSION;
|
|
82
|
+
const latest = cached.latestVersion;
|
|
83
|
+
const line1 = ` Update available: ${current} → ${latest}`;
|
|
84
|
+
const line2 = ` Run /update or:`;
|
|
85
|
+
const line3 = ` npm install -g ${UPDATE_DEFAULTS.npmPackageName}@latest`;
|
|
86
|
+
const maxLen = Math.max(line1.length, line2.length, line3.length) + 2;
|
|
87
|
+
const pad = (s) => s + ' '.repeat(Math.max(0, maxLen - s.length));
|
|
88
|
+
return [
|
|
89
|
+
`╭${'─'.repeat(maxLen)}╮`,
|
|
90
|
+
`│${pad(line1)}│`,
|
|
91
|
+
`│${pad(line2)}│`,
|
|
92
|
+
`│${pad(line3)}│`,
|
|
93
|
+
`╰${'─'.repeat(maxLen)}╯`,
|
|
94
|
+
].join('\n');
|
|
95
|
+
}
|
|
96
|
+
export function performUpdate() {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
99
|
+
execFile(npmCmd, ['install', '-g', `${UPDATE_DEFAULTS.npmPackageName}@latest`], {
|
|
100
|
+
timeout: 120_000,
|
|
101
|
+
env: { ...process.env },
|
|
102
|
+
}, (error, stdout, stderr) => {
|
|
103
|
+
if (error) {
|
|
104
|
+
resolve({ success: false, output: stderr || error.message });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
resolve({ success: true, output: stdout.trim() });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const WORKTREE_DIR = '.scalix/worktrees';
|
|
8
|
+
const BRANCH_PREFIX = 'scalix-agent/';
|
|
9
|
+
async function isGitRepo(dir) {
|
|
10
|
+
try {
|
|
11
|
+
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir });
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function getCurrentBranch(dir) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
|
|
21
|
+
return stdout.trim();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return 'HEAD';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function createWorktree(agentId, basePath) {
|
|
28
|
+
if (!await isGitRepo(basePath)) {
|
|
29
|
+
throw new Error('Not a git repository — worktree isolation requires git');
|
|
30
|
+
}
|
|
31
|
+
const safeName = agentId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40);
|
|
32
|
+
const worktreePath = path.join(basePath, WORKTREE_DIR, safeName);
|
|
33
|
+
const branch = `${BRANCH_PREFIX}${safeName}`;
|
|
34
|
+
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
|
35
|
+
try {
|
|
36
|
+
await execFileAsync('git', ['worktree', 'add', worktreePath, '-b', branch], {
|
|
37
|
+
cwd: basePath,
|
|
38
|
+
timeout: 30_000,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
await fs.rm(worktreePath, { recursive: true, force: true }).catch(() => { });
|
|
43
|
+
await execFileAsync('git', ['branch', '-D', branch], { cwd: basePath }).catch(() => { });
|
|
44
|
+
throw new Error(`Failed to create worktree: ${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
logger.info('Created worktree for agent', { agentId, path: worktreePath, branch });
|
|
47
|
+
return {
|
|
48
|
+
path: worktreePath,
|
|
49
|
+
branch,
|
|
50
|
+
agentId,
|
|
51
|
+
createdAt: Date.now(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function removeWorktree(info, basePath) {
|
|
55
|
+
try {
|
|
56
|
+
await execFileAsync('git', ['worktree', 'remove', info.path, '--force'], {
|
|
57
|
+
cwd: basePath,
|
|
58
|
+
timeout: 30_000,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
logger.warn('Failed to remove worktree via git, cleaning up manually', { error: err.message });
|
|
63
|
+
try {
|
|
64
|
+
await fs.rm(info.path, { recursive: true, force: true });
|
|
65
|
+
await execFileAsync('git', ['worktree', 'prune'], { cwd: basePath, timeout: 10_000 });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// best effort
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await execFileAsync('git', ['branch', '-D', info.branch], {
|
|
73
|
+
cwd: basePath,
|
|
74
|
+
timeout: 10_000,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// branch may already be deleted
|
|
79
|
+
}
|
|
80
|
+
logger.info('Removed worktree', { agentId: info.agentId, path: info.path });
|
|
81
|
+
}
|
|
82
|
+
export async function mergeWorktree(info, basePath, targetBranch) {
|
|
83
|
+
const target = targetBranch || await getCurrentBranch(basePath);
|
|
84
|
+
// Check if worktree has any commits beyond the branch point
|
|
85
|
+
try {
|
|
86
|
+
const { stdout: logOutput } = await execFileAsync('git', ['log', `${target}..${info.branch}`, '--oneline'], { cwd: basePath, timeout: 10_000 });
|
|
87
|
+
const commits = logOutput.trim().split('\n').filter(l => l.length > 0);
|
|
88
|
+
if (commits.length === 0) {
|
|
89
|
+
return { merged: true, commitCount: 0 };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await execFileAsync('git', ['merge', info.branch, '--no-edit'], {
|
|
93
|
+
cwd: basePath,
|
|
94
|
+
timeout: 30_000,
|
|
95
|
+
});
|
|
96
|
+
return { merged: true, commitCount: commits.length };
|
|
97
|
+
}
|
|
98
|
+
catch (mergeErr) {
|
|
99
|
+
// Merge conflict — abort and report
|
|
100
|
+
try {
|
|
101
|
+
await execFileAsync('git', ['merge', '--abort'], { cwd: basePath, timeout: 10_000 });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// best effort abort
|
|
105
|
+
}
|
|
106
|
+
// Get list of conflicting files
|
|
107
|
+
const { stdout: diffOutput } = await execFileAsync('git', ['diff', '--name-only', target, info.branch], { cwd: basePath, timeout: 10_000 });
|
|
108
|
+
const conflicts = diffOutput.trim().split('\n').filter(l => l.length > 0);
|
|
109
|
+
return { merged: false, conflicts, commitCount: commits.length };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { merged: true, commitCount: 0 };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export async function listWorktrees(basePath) {
|
|
117
|
+
try {
|
|
118
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
|
|
119
|
+
cwd: basePath,
|
|
120
|
+
timeout: 10_000,
|
|
121
|
+
});
|
|
122
|
+
const worktrees = [];
|
|
123
|
+
const entries = stdout.split('\n\n');
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
const lines = entry.trim().split('\n');
|
|
126
|
+
const worktreeLine = lines.find(l => l.startsWith('worktree '));
|
|
127
|
+
const branchLine = lines.find(l => l.startsWith('branch '));
|
|
128
|
+
if (!worktreeLine || !branchLine)
|
|
129
|
+
continue;
|
|
130
|
+
const wtPath = worktreeLine.slice('worktree '.length);
|
|
131
|
+
const fullBranch = branchLine.slice('branch '.length);
|
|
132
|
+
const branch = fullBranch.replace('refs/heads/', '');
|
|
133
|
+
if (!branch.startsWith(BRANCH_PREFIX))
|
|
134
|
+
continue;
|
|
135
|
+
const agentId = branch.slice(BRANCH_PREFIX.length);
|
|
136
|
+
worktrees.push({
|
|
137
|
+
path: wtPath,
|
|
138
|
+
branch,
|
|
139
|
+
agentId,
|
|
140
|
+
createdAt: 0, // unknown from git porcelain
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return worktrees;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function cleanupStaleWorktrees(basePath, maxAgeMs = 3600_000) {
|
|
150
|
+
const worktrees = await listWorktrees(basePath);
|
|
151
|
+
let cleaned = 0;
|
|
152
|
+
for (const wt of worktrees) {
|
|
153
|
+
try {
|
|
154
|
+
const stat = await fs.stat(wt.path);
|
|
155
|
+
if (Date.now() - stat.mtimeMs > maxAgeMs) {
|
|
156
|
+
await removeWorktree(wt, basePath);
|
|
157
|
+
cleaned++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// worktree path doesn't exist, remove reference
|
|
162
|
+
try {
|
|
163
|
+
await execFileAsync('git', ['worktree', 'prune'], { cwd: basePath, timeout: 10_000 });
|
|
164
|
+
cleaned++;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// best effort
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return cleaned;
|
|
172
|
+
}
|