scalix 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +29 -0
- package/PARITY-PLAN.md +116 -0
- package/README.md +1 -1
- package/bin/scalix.js +1 -0
- package/dist/agent-definitions.js +100 -0
- package/dist/agent-registry.js +226 -0
- package/dist/agent.js +363 -74
- package/dist/api-client.js +77 -0
- package/dist/cli.js +724 -76
- package/dist/commands.js +53 -2
- package/dist/constants.js +43 -9
- package/dist/context-builder.js +8 -1
- package/dist/context-retriever.js +257 -0
- package/dist/git-context.js +32 -29
- package/dist/grounding-checker.js +154 -0
- package/dist/headless.js +149 -20
- package/dist/hooks.js +148 -10
- package/dist/injection-detector.js +86 -0
- package/dist/ipc-server.js +6 -2
- package/dist/keychain.js +109 -0
- package/dist/markdown.js +46 -3
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +22 -1
- package/dist/sandbox.js +201 -11
- package/dist/sessions.js +96 -2
- package/dist/settings.js +194 -8
- package/dist/skills.js +269 -0
- package/dist/sub-agent.js +83 -8
- package/dist/task-board.js +105 -0
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- package/dist/tools-advanced.js +304 -49
- package/dist/tools-browser.js +280 -19
- package/dist/tools-extra.js +9 -8
- package/dist/tools-vision.js +4 -3
- package/dist/tools.js +13 -3
- package/dist/update-checker.js +111 -0
- package/dist/worktree.js +172 -0
- package/package.json +8 -3
- package/app.py +0 -55
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scalix",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "AI coding agent for the terminal — edit code, run commands, and build with Scalix",
|
|
5
5
|
"main": "dist/headless.js",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"test": "vitest run",
|
|
17
17
|
"test:watch": "vitest",
|
|
18
18
|
"test:coverage": "vitest run --coverage",
|
|
19
|
+
"test:e2e": "vitest run --config vitest.config.e2e.ts",
|
|
19
20
|
"check": "npm run typecheck && npm run lint && npm run test",
|
|
20
21
|
"prepublishOnly": "npm run build",
|
|
21
22
|
"release": "npm run build && node scripts/build-release.js"
|
|
@@ -56,18 +57,22 @@
|
|
|
56
57
|
"ink-text-input": "^6.0.0",
|
|
57
58
|
"marked": "^15.0.12",
|
|
58
59
|
"marked-terminal": "^7.3.0",
|
|
59
|
-
"openai": "^4.73.0",
|
|
60
60
|
"react": "^19.2.4",
|
|
61
61
|
"zod": "^3.23.8"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@types/node": "^25.
|
|
64
|
+
"@types/node": "^25.6.0",
|
|
65
65
|
"@types/react": "^19.2.14",
|
|
66
66
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
67
67
|
"@typescript-eslint/parser": "^8.0.0",
|
|
68
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
68
69
|
"eslint": "^9.0.0",
|
|
70
|
+
"playwright": "^1.59.1",
|
|
69
71
|
"tsx": "^4.21.0",
|
|
70
72
|
"typescript": "^6.0.2",
|
|
71
73
|
"vitest": "^3.0.0"
|
|
74
|
+
},
|
|
75
|
+
"optionalDependencies": {
|
|
76
|
+
"playwright": "^1.59.1"
|
|
72
77
|
}
|
|
73
78
|
}
|
package/app.py
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
from flask import Flask, request, jsonify
|
|
2
|
-
from flask_cors import CORS
|
|
3
|
-
|
|
4
|
-
app = Flask(__name__)
|
|
5
|
-
CORS(app) # Enable CORS for all routes
|
|
6
|
-
|
|
7
|
-
# In-memory storage for todos
|
|
8
|
-
todos = []
|
|
9
|
-
next_id = 1
|
|
10
|
-
|
|
11
|
-
@app.route('/todos', methods=['GET'])
|
|
12
|
-
def get_todos():
|
|
13
|
-
return jsonify(todos)
|
|
14
|
-
|
|
15
|
-
@app.route('/todos', methods=['POST'])
|
|
16
|
-
def add_todo():
|
|
17
|
-
global next_id
|
|
18
|
-
data = request.get_json()
|
|
19
|
-
if not data or 'task' not in data:
|
|
20
|
-
return jsonify({'error': 'Task is required'}), 400
|
|
21
|
-
|
|
22
|
-
new_todo = {
|
|
23
|
-
'id': next_id,
|
|
24
|
-
'task': data['task'],
|
|
25
|
-
'completed': data.get('completed', False)
|
|
26
|
-
}
|
|
27
|
-
todos.append(new_todo)
|
|
28
|
-
next_id += 1
|
|
29
|
-
return jsonify(new_todo), 201
|
|
30
|
-
|
|
31
|
-
@app.route('/todos/<int:todo_id>', methods=['PUT'])
|
|
32
|
-
def update_todo(todo_id):
|
|
33
|
-
data = request.get_json()
|
|
34
|
-
for todo in todos:
|
|
35
|
-
if todo['id'] == todo_id:
|
|
36
|
-
if 'task' in data:
|
|
37
|
-
todo['task'] = data['task']
|
|
38
|
-
if 'completed' in data:
|
|
39
|
-
todo['completed'] = data['completed']
|
|
40
|
-
return jsonify(todo)
|
|
41
|
-
return jsonify({'error': 'Todo not found'}), 404
|
|
42
|
-
|
|
43
|
-
@app.route('/todos/<int:todo_id>', methods=['DELETE'])
|
|
44
|
-
def delete_todo(todo_id):
|
|
45
|
-
global todos
|
|
46
|
-
initial_length = len(todos)
|
|
47
|
-
todos = [todo for todo in todos if todo['id'] != todo_id]
|
|
48
|
-
if len(todos) < initial_length:
|
|
49
|
-
return jsonify({'message': 'Todo deleted'})
|
|
50
|
-
return jsonify({'error': 'Todo not found'}), 404
|
|
51
|
-
|
|
52
|
-
if __name__ == '__main__':
|
|
53
|
-
# Note: For production, use a proper WSGI server like Gunicorn.
|
|
54
|
-
# For development, Flask's built-in server is fine.
|
|
55
|
-
app.run(debug=True, port=5000) # Specify port for clarity
|