atris 2.6.2 → 3.0.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/README.md +124 -34
- package/atris/CLAUDE.md +5 -1
- package/atris/atris.md +4 -0
- package/atris/features/README.md +24 -0
- package/atris/skills/autopilot/SKILL.md +74 -75
- package/atris/skills/endgame/SKILL.md +179 -0
- package/atris/skills/flow/SKILL.md +121 -0
- package/atris/skills/improve/SKILL.md +84 -0
- package/atris/skills/loop/SKILL.md +72 -0
- package/atris/skills/wiki/SKILL.md +61 -0
- package/atris/team/executor/MEMBER.md +10 -4
- package/atris/team/navigator/MEMBER.md +2 -0
- package/atris/team/validator/MEMBER.md +8 -5
- package/atris.md +33 -0
- package/bin/atris.js +210 -41
- package/commands/activate.js +28 -2
- package/commands/align.js +720 -0
- package/commands/auth.js +75 -2
- package/commands/autopilot.js +1213 -270
- package/commands/browse.js +100 -0
- package/commands/business.js +785 -12
- package/commands/clean.js +107 -2
- package/commands/computer.js +429 -0
- package/commands/context-sync.js +78 -8
- package/commands/experiments.js +351 -0
- package/commands/feedback.js +150 -0
- package/commands/fleet.js +395 -0
- package/commands/fork.js +127 -0
- package/commands/init.js +50 -1
- package/commands/learn.js +407 -0
- package/commands/lifecycle.js +94 -0
- package/commands/loop.js +114 -0
- package/commands/publish.js +129 -0
- package/commands/pull.js +434 -48
- package/commands/push.js +312 -164
- package/commands/review.js +149 -0
- package/commands/run.js +76 -43
- package/commands/serve.js +360 -0
- package/commands/setup.js +1 -1
- package/commands/soul.js +381 -0
- package/commands/status.js +119 -1
- package/commands/sync.js +147 -1
- package/commands/terminal.js +201 -0
- package/commands/wiki.js +376 -0
- package/commands/workflow.js +191 -74
- package/commands/workspace-clean.js +3 -3
- package/lib/endstate.js +259 -0
- package/lib/learnings.js +235 -0
- package/lib/manifest.js +1 -0
- package/lib/todo.js +9 -5
- package/lib/wiki.js +578 -0
- package/package.json +2 -2
- package/utils/api.js +48 -36
- package/utils/auth.js +1 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atris Serve — make this directory a live AI Computer.
|
|
3
|
+
*
|
|
4
|
+
* atris serve Start the bridge in current directory
|
|
5
|
+
* atris serve --agent <agent_id> Bind to a specific agent
|
|
6
|
+
* atris serve --once <op_id> Apply one queued op and exit (debug)
|
|
7
|
+
*
|
|
8
|
+
* Opens a session with the Atris backend, subscribes via SSE to incoming
|
|
9
|
+
* file operations, and applies them to the local working directory.
|
|
10
|
+
*
|
|
11
|
+
* Cloud agents (or any authenticated caller) can dispatch operations:
|
|
12
|
+
* POST /api/cli/sessions/{id}/file-op
|
|
13
|
+
*
|
|
14
|
+
* Operations supported:
|
|
15
|
+
* - write: create or replace a file
|
|
16
|
+
* - edit: find/replace in a file
|
|
17
|
+
* - read: read a file (returns content via ack)
|
|
18
|
+
* - delete: remove a file
|
|
19
|
+
* - bash: run a shell command
|
|
20
|
+
*
|
|
21
|
+
* Path safety: all paths are resolved against the working directory.
|
|
22
|
+
* Anything that escapes is rejected.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const https = require('https');
|
|
28
|
+
const http = require('http');
|
|
29
|
+
const { execSync } = require('child_process');
|
|
30
|
+
const { loadCredentials } = require('../utils/auth');
|
|
31
|
+
const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
|
|
32
|
+
|
|
33
|
+
const HEARTBEAT_INTERVAL_MS = 30000;
|
|
34
|
+
const RECONNECT_DELAY_MS = 2000;
|
|
35
|
+
const MAX_RECONNECT_DELAY_MS = 30000;
|
|
36
|
+
// Bash commands are bounded to 10s — long batches won't lock the CLI for hours
|
|
37
|
+
const BASH_TIMEOUT_MS = 10000;
|
|
38
|
+
// Hard size limits to prevent OOM on large payloads
|
|
39
|
+
const MAX_WRITE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
40
|
+
const MAX_EDIT_BYTES = 1 * 1024 * 1024; // 1 MB find/replace
|
|
41
|
+
|
|
42
|
+
function getToken() {
|
|
43
|
+
const creds = loadCredentials();
|
|
44
|
+
if (!creds || !creds.token) {
|
|
45
|
+
console.error('Not logged in. Run: atris login');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
return creds.token;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate a path stays inside the working directory.
|
|
53
|
+
* Returns the absolute resolved path or throws.
|
|
54
|
+
*/
|
|
55
|
+
function safePath(workingDir, requestedPath) {
|
|
56
|
+
if (!requestedPath || typeof requestedPath !== 'string') {
|
|
57
|
+
throw new Error('path required');
|
|
58
|
+
}
|
|
59
|
+
if (requestedPath.startsWith('/')) {
|
|
60
|
+
throw new Error('path must be relative');
|
|
61
|
+
}
|
|
62
|
+
if (requestedPath.split('/').includes('..')) {
|
|
63
|
+
throw new Error('path may not contain ..');
|
|
64
|
+
}
|
|
65
|
+
const realWd = fs.realpathSync(workingDir);
|
|
66
|
+
const resolved = path.resolve(realWd, requestedPath);
|
|
67
|
+
// Walk up to find a parent that exists, then realpath that
|
|
68
|
+
let parent = path.dirname(resolved);
|
|
69
|
+
while (parent && !fs.existsSync(parent) && parent !== path.dirname(parent)) {
|
|
70
|
+
parent = path.dirname(parent);
|
|
71
|
+
}
|
|
72
|
+
const realParent = fs.existsSync(parent) ? fs.realpathSync(parent) : parent;
|
|
73
|
+
// The realParent must be inside realWd
|
|
74
|
+
if (!realParent.startsWith(realWd + path.sep) && realParent !== realWd) {
|
|
75
|
+
throw new Error('path escapes working directory');
|
|
76
|
+
}
|
|
77
|
+
return resolved;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Apply a single operation locally. Returns { status, result }.
|
|
82
|
+
*/
|
|
83
|
+
async function applyOp(workingDir, op) {
|
|
84
|
+
try {
|
|
85
|
+
const type = op.type;
|
|
86
|
+
|
|
87
|
+
if (type === 'write') {
|
|
88
|
+
const content = op.content || '';
|
|
89
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
90
|
+
if (bytes > MAX_WRITE_BYTES) {
|
|
91
|
+
return { status: 'error', result: { error: `content exceeds ${MAX_WRITE_BYTES} bytes` } };
|
|
92
|
+
}
|
|
93
|
+
const target = safePath(workingDir, op.path);
|
|
94
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
95
|
+
fs.writeFileSync(target, content, 'utf8');
|
|
96
|
+
return { status: 'ok', result: { bytes_written: bytes } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (type === 'read') {
|
|
100
|
+
const target = safePath(workingDir, op.path);
|
|
101
|
+
if (!fs.existsSync(target)) {
|
|
102
|
+
return { status: 'error', result: { error: 'file not found' } };
|
|
103
|
+
}
|
|
104
|
+
const content = fs.readFileSync(target, 'utf8');
|
|
105
|
+
return { status: 'ok', result: { content, bytes: Buffer.byteLength(content, 'utf8') } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (type === 'edit') {
|
|
109
|
+
if (Buffer.byteLength(op.find || '', 'utf8') > MAX_EDIT_BYTES ||
|
|
110
|
+
Buffer.byteLength(op.replace || '', 'utf8') > MAX_EDIT_BYTES) {
|
|
111
|
+
return { status: 'error', result: { error: `find/replace exceeds ${MAX_EDIT_BYTES} bytes` } };
|
|
112
|
+
}
|
|
113
|
+
const target = safePath(workingDir, op.path);
|
|
114
|
+
if (!fs.existsSync(target)) {
|
|
115
|
+
return { status: 'error', result: { error: 'file not found' } };
|
|
116
|
+
}
|
|
117
|
+
const stat = fs.statSync(target);
|
|
118
|
+
if (stat.size > MAX_WRITE_BYTES) {
|
|
119
|
+
return { status: 'error', result: { error: `file too large (>${MAX_WRITE_BYTES} bytes)` } };
|
|
120
|
+
}
|
|
121
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
122
|
+
if (!original.includes(op.find)) {
|
|
123
|
+
return { status: 'error', result: { error: 'find string not present' } };
|
|
124
|
+
}
|
|
125
|
+
const updated = original.split(op.find).join(op.replace);
|
|
126
|
+
fs.writeFileSync(target, updated, 'utf8');
|
|
127
|
+
return { status: 'ok', result: { replacements: original.split(op.find).length - 1 } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (type === 'delete') {
|
|
131
|
+
const target = safePath(workingDir, op.path);
|
|
132
|
+
if (!fs.existsSync(target)) {
|
|
133
|
+
return { status: 'error', result: { error: 'file not found' } };
|
|
134
|
+
}
|
|
135
|
+
fs.unlinkSync(target);
|
|
136
|
+
return { status: 'ok', result: { deleted: true } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (type === 'bash') {
|
|
140
|
+
// Execute bash in the working directory with a timeout.
|
|
141
|
+
// Note: This is a powerful op — only the session owner can dispatch it,
|
|
142
|
+
// AND the session must have been created with allow_bash=true.
|
|
143
|
+
try {
|
|
144
|
+
const stdout = execSync(op.command, {
|
|
145
|
+
cwd: workingDir,
|
|
146
|
+
timeout: BASH_TIMEOUT_MS,
|
|
147
|
+
encoding: 'utf8',
|
|
148
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
149
|
+
});
|
|
150
|
+
const truncated = stdout.length > 100000;
|
|
151
|
+
return {
|
|
152
|
+
status: 'ok',
|
|
153
|
+
result: { stdout: stdout.slice(0, 100000), truncated, exit_code: 0 },
|
|
154
|
+
};
|
|
155
|
+
} catch (execErr) {
|
|
156
|
+
const stderr = (execErr.stderr || '').toString();
|
|
157
|
+
const stdout = (execErr.stdout || '').toString();
|
|
158
|
+
return {
|
|
159
|
+
status: 'error',
|
|
160
|
+
result: {
|
|
161
|
+
error: execErr.message,
|
|
162
|
+
stdout: stdout.slice(0, 100000),
|
|
163
|
+
stderr: stderr.slice(0, 100000),
|
|
164
|
+
stdout_truncated: stdout.length > 100000,
|
|
165
|
+
stderr_truncated: stderr.length > 100000,
|
|
166
|
+
exit_code: execErr.status ?? 1,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { status: 'error', result: { error: `unknown op type: ${type}` } };
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return { status: 'error', result: { error: err.message } };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Open an SSE stream to /api/cli/sessions/{id}/events and apply each op.
|
|
180
|
+
*/
|
|
181
|
+
function streamSession(token, sessionId, workingDir) {
|
|
182
|
+
const baseUrl = getApiBaseUrl();
|
|
183
|
+
const url = new URL(`${baseUrl}/cli/sessions/${sessionId}/events`);
|
|
184
|
+
const isHttps = url.protocol === 'https:';
|
|
185
|
+
const transport = isHttps ? https : http;
|
|
186
|
+
|
|
187
|
+
const options = {
|
|
188
|
+
method: 'GET',
|
|
189
|
+
hostname: url.hostname,
|
|
190
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
191
|
+
path: `${url.pathname}${url.search}`,
|
|
192
|
+
headers: {
|
|
193
|
+
'Authorization': `Bearer ${token}`,
|
|
194
|
+
'Accept': 'text/event-stream',
|
|
195
|
+
'Cache-Control': 'no-cache',
|
|
196
|
+
},
|
|
197
|
+
timeout: 0,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
const req = transport.request(options, (res) => {
|
|
202
|
+
if (res.statusCode !== 200) {
|
|
203
|
+
let body = '';
|
|
204
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
205
|
+
res.on('end', () => reject(new Error(`SSE failed: ${res.statusCode} ${body}`)));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
console.log(`● Bridge active — listening for ops on session ${sessionId.slice(0, 8)}...`);
|
|
210
|
+
|
|
211
|
+
let buffer = '';
|
|
212
|
+
res.on('data', async (chunk) => {
|
|
213
|
+
buffer += chunk.toString();
|
|
214
|
+
const messages = buffer.split('\n\n');
|
|
215
|
+
buffer = messages.pop() || '';
|
|
216
|
+
|
|
217
|
+
for (const msg of messages) {
|
|
218
|
+
if (!msg.startsWith('data: ')) continue;
|
|
219
|
+
const dataStr = msg.slice(6).trim();
|
|
220
|
+
if (!dataStr) continue;
|
|
221
|
+
|
|
222
|
+
let event;
|
|
223
|
+
try {
|
|
224
|
+
event = JSON.parse(dataStr);
|
|
225
|
+
} catch {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (event.type === 'ping' || event.type === 'hello') {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (event.type === 'close') {
|
|
233
|
+
console.log(' ✓ Session closed by backend');
|
|
234
|
+
res.destroy();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Apply the operation
|
|
239
|
+
const startMs = Date.now();
|
|
240
|
+
const ackPayload = await applyOp(workingDir, event);
|
|
241
|
+
const durationMs = Date.now() - startMs;
|
|
242
|
+
|
|
243
|
+
const icon = ackPayload.status === 'ok' ? '✓' : '✗';
|
|
244
|
+
console.log(` ${icon} ${event.type} ${event.path || event.command || ''} (${durationMs}ms)`);
|
|
245
|
+
|
|
246
|
+
// Send the ack
|
|
247
|
+
try {
|
|
248
|
+
await apiRequestJson(`/cli/sessions/${sessionId}/ack`, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
token,
|
|
251
|
+
body: {
|
|
252
|
+
op_id: event.op_id,
|
|
253
|
+
status: ackPayload.status,
|
|
254
|
+
result: ackPayload.result,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
} catch (ackErr) {
|
|
258
|
+
console.error(` failed to ack: ${ackErr.message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
res.on('end', () => {
|
|
264
|
+
console.log(' · Stream ended');
|
|
265
|
+
resolve();
|
|
266
|
+
});
|
|
267
|
+
res.on('error', reject);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
req.on('error', reject);
|
|
271
|
+
req.end();
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function serveAtris(options = {}) {
|
|
276
|
+
const token = getToken();
|
|
277
|
+
const workingDir = process.cwd();
|
|
278
|
+
const agentId = options.agent || null;
|
|
279
|
+
const allowBash = options.allowBash === true;
|
|
280
|
+
|
|
281
|
+
console.log('');
|
|
282
|
+
console.log('╭──────────────────────────────────────────╮');
|
|
283
|
+
console.log('│ ATRIS SERVE — Local AI Computer Bridge │');
|
|
284
|
+
console.log('╰──────────────────────────────────────────╯');
|
|
285
|
+
console.log('');
|
|
286
|
+
console.log(` 📁 Directory: ${workingDir}`);
|
|
287
|
+
console.log(` 🤖 Agent: ${agentId || '(none)'}`);
|
|
288
|
+
console.log(` ⚡ Bash: ${allowBash ? 'enabled (REMOTE BASH ALLOWED)' : 'disabled (read/write/edit/delete only)'}`);
|
|
289
|
+
console.log('');
|
|
290
|
+
|
|
291
|
+
// Register the session
|
|
292
|
+
let session;
|
|
293
|
+
try {
|
|
294
|
+
const result = await apiRequestJson('/cli/sessions', {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
token,
|
|
297
|
+
body: {
|
|
298
|
+
working_directory: workingDir,
|
|
299
|
+
agent_id: agentId,
|
|
300
|
+
allow_bash: allowBash,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
if (!result.ok) {
|
|
304
|
+
console.error(`✗ Failed to create session: ${result.errorMessage || result.status}`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
session = result.data;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error(`✗ Could not register session: ${err.message}`);
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
console.log(` ✓ Session: ${session.session_id}`);
|
|
314
|
+
console.log('');
|
|
315
|
+
console.log(' Cloud agents can now modify files in this directory via:');
|
|
316
|
+
console.log(` POST /api/cli/sessions/${session.session_id}/file-op`);
|
|
317
|
+
console.log('');
|
|
318
|
+
console.log(' Press Ctrl+C to stop.');
|
|
319
|
+
console.log('');
|
|
320
|
+
|
|
321
|
+
// Cleanup on exit
|
|
322
|
+
let shuttingDown = false;
|
|
323
|
+
const shutdown = async () => {
|
|
324
|
+
if (shuttingDown) return;
|
|
325
|
+
shuttingDown = true;
|
|
326
|
+
console.log('\n · Closing session...');
|
|
327
|
+
try {
|
|
328
|
+
await apiRequestJson(`/cli/sessions/${session.session_id}`, {
|
|
329
|
+
method: 'DELETE',
|
|
330
|
+
token,
|
|
331
|
+
});
|
|
332
|
+
} catch {
|
|
333
|
+
// best effort
|
|
334
|
+
}
|
|
335
|
+
process.exit(0);
|
|
336
|
+
};
|
|
337
|
+
process.on('SIGINT', shutdown);
|
|
338
|
+
process.on('SIGTERM', shutdown);
|
|
339
|
+
|
|
340
|
+
// Reconnect loop with exponential backoff
|
|
341
|
+
let reconnectDelay = RECONNECT_DELAY_MS;
|
|
342
|
+
while (!shuttingDown) {
|
|
343
|
+
try {
|
|
344
|
+
await streamSession(token, session.session_id, workingDir);
|
|
345
|
+
reconnectDelay = RECONNECT_DELAY_MS; // reset on clean disconnect
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (shuttingDown) break;
|
|
348
|
+
console.error(` ⚠ Stream error: ${err.message}, reconnecting in ${reconnectDelay / 1000}s...`);
|
|
349
|
+
await new Promise((r) => setTimeout(r, reconnectDelay));
|
|
350
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = {
|
|
356
|
+
serveAtris,
|
|
357
|
+
// exported for testing
|
|
358
|
+
safePath,
|
|
359
|
+
applyOp,
|
|
360
|
+
};
|
package/commands/setup.js
CHANGED
|
@@ -68,7 +68,7 @@ async function setupAtris() {
|
|
|
68
68
|
console.log(' [3/4] Fetching your businesses...');
|
|
69
69
|
let businesses = [];
|
|
70
70
|
try {
|
|
71
|
-
const result = await apiRequestJson('/
|
|
71
|
+
const result = await apiRequestJson('/business/', {
|
|
72
72
|
method: 'GET',
|
|
73
73
|
token: creds.token,
|
|
74
74
|
});
|